Skip to content
Go back

Durable Objects: Stateful Serverless

By SumGuy 12 min read
Durable Objects: Stateful Serverless
Contents

The Problem With Stateless Everything

Full example: Clone the working rate limiter, including the test script that proves the window actually resets, at github.com/KingPin/sumguy-examples/devops/durable-objects-stateful-serverless

Serverless promised you’d never think about servers again, and mostly it delivers. Cloudflare Workers spin up in milliseconds, scale to zero when nobody’s asking, and bill you in fractions of a cent. Then you build something that needs to remember a number between two requests, like a rate limiter, and the whole model falls apart.

Your first instinct is probably KV. Write a counter, read it back, increment it. Except KV is eventually consistent by design: it replicates globally and a read can lag a write by up to 60 seconds. Two requests hitting two different edge locations at the same moment can both read “4” and both write “5,” and now your rate limiter thinks four requests happened instead of five. For a homepage view counter, nobody cares. For a rate limiter protecting a paid API, that’s a hole you can drive a truck through.

This is the gap Durable Objects fill. A Durable Object is a single instance of a class, addressed by a name you choose, that every request for that name gets routed to, one at a time. No two requests for the same object run concurrently. No optimistic-concurrency retry loop, no distributed lock, no “eventually.” Just an actor that owns its own state and processes messages in order, the same idea Erlang and Akka shipped decades ago, except Cloudflare runs it on their edge network and hands you a URL.

That single-threaded guarantee is the whole feature. It’s also, if you’re not careful, the whole liability, and I’ll get into exactly how in the gotchas section below.

What A Durable Object Actually Is

Forget “serverless function” for a second and think “process with an address.” You define a class. Cloudflare instantiates exactly one running copy of it per unique ID you ask for, anywhere in the world. Ask for the same ID twice and you get routed to the same instance, wherever it happens to be running. Requests to that instance are serialized: request two waits for request one to finish before it starts.

That’s the actor model in one paragraph: identity, mailbox, sequential processing, private state. The mailbox here is HTTP requests, or RPC calls (Workers have supported calling methods on a Durable Object directly, not just fetch, since compatibility date 2024-04-03). The private state lives in ctx.storage, which persists across restarts and even across the object being evicted from memory.

Compare that to a normal Worker: stateless, ephemeral, and every incoming request might land on a different physical machine with zero memory of the last one. A Durable Object is the one exception you carve out on purpose, for the parts of your app that need a single source of truth.

Pick Your Storage Weapon

Cloudflare gives you four storage primitives, and they are not interchangeable, even though the marketing copy sometimes makes them sound that way.

The tell for “this needs a Durable Object” is the word coordination. If two requests touching the same piece of state must never race each other, that’s your signal. If you just need fast reads of mostly-static data, that’s KV. If you need a real dataset with relationships, that’s D1.

The Storage API, SQLite Underneath

Every new Durable Objects namespace is SQLite-backed now, which is also what unlocked running Durable Objects on the Workers Free plan; you don’t need a Paid subscription for basic use anymore. Under ctx.storage you get two ways to touch that SQLite database.

The key-value style API is the one you’ll reach for most:

src/rate-limiter.ts
await this.ctx.storage.put("count", 5);
const count = await this.ctx.storage.get<number>("count");
await this.ctx.storage.delete("count");

It works fine on SQLite-backed objects too; Cloudflare just maps it onto a table under the hood. When you need actual SQL, ctx.storage.sql.exec() gives you a real cursor:

const row = this.ctx.storage.sql
.exec("SELECT count(*) as total FROM events WHERE key = ?", apiKey)
.one();

Writes inside a single Durable Object are effectively transactional by construction, because only one request executes at a time, so there’s no interleaving to guard against. ctx.storage.transaction() is still there for explicit rollback boundaries across multiple operations, but you won’t need row-level locking the way you would against a shared database. Storage also ships with point-in-time recovery: you can restore a SQLite-backed object’s data to any point within the last 30 days, which is more disaster recovery than most homelab side projects bother building for themselves.

One cleanup gotcha: deleting individual keys doesn’t stop billing for the object. If you actually want to tear one down, call storage.deleteAll() (and storage.deleteAlarm() if you set one). Leaving orphaned objects around with a few bytes in them is a quiet way to accumulate a storage bill you can’t explain later.

WebSocket Hibernation: The Feature Homelabbers Actually Want

If you build one thing with Durable Objects for a homelab project, make it something with WebSockets, because hibernation is the feature nobody advertises loudly enough.

Normally, a WebSocket connection keeps its Durable Object pinned in memory the entire time it’s open, and you’re billed for that wall-clock duration whether anything is happening or not. A chat room with a thousand idle lurkers racks up duration charges for doing nothing.

The Hibernation API changes that. Instead of the standard ws.accept(), you call this.ctx.acceptWebSocket(ws). The runtime can then evict the Durable Object from memory entirely while the WebSocket connections stay open at the edge. When a message actually arrives, the object rehydrates, handles it, and can go back to sleep. You implement webSocketMessage(), webSocketClose(), and webSocketError() as methods on the class instead of attaching listeners to the socket object, and the runtime calls them once it wakes the object back up:

export class ChatRoom extends DurableObject {
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string) {
// broadcast, persist, whatever your room needs
}
async webSocketClose(ws: WebSocket, code: number) {
ws.close(code, "closing");
}
}

Billable duration (GB-seconds) doesn’t accrue while the object is hibernating. That’s the whole pitch: thousands of long-lived, mostly-idle connections, and you only pay for the moments something actually happens. A webhook aggregator, a status dashboard with a handful of live viewers, a comment stream: all shapes that spend nearly all their life doing nothing, which is exactly what this billing model rewards.

Building It: A Rate Limiter Per API Key

Enough theory. Here’s a complete, working rate limiter: one Durable Object per API key, a fixed window, and a 429 when you go over.

src/rate-limiter.ts
import { DurableObject } from "cloudflare:workers";
const WINDOW_MS = 60_000;
const LIMIT = 30;
export class RateLimiter extends DurableObject {
async fetch(): Promise<Response> {
const now = Date.now();
const windowStart = await this.ctx.storage.get<number>("windowStart");
let count = (await this.ctx.storage.get<number>("count")) ?? 0;
// Start a fresh window on the first ever request, and whenever the
// current one has expired. Persisting windowStart here is the whole
// trick: skip it and the window never actually begins.
if (windowStart === undefined || now - windowStart > WINDOW_MS) {
count = 0;
await this.ctx.storage.put("windowStart", now);
}
count += 1;
await this.ctx.storage.put("count", count);
if (count > LIMIT) {
return new Response("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60" },
});
}
return new Response("OK");
}
}

The Worker in front of it looks up the object by API key and forwards the check:

src/index.ts
import { RateLimiter } from "./rate-limiter";
export { RateLimiter };
interface Env {
RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) {
return new Response("Missing x-api-key header", { status: 401 });
}
const stub = env.RATE_LIMITER.getByName(apiKey);
const limiterResponse = await stub.fetch(request);
if (limiterResponse.status === 429) {
return limiterResponse;
}
return new Response(`request allowed for ${apiKey}`);
},
};

getByName() is the current shortcut: hand it a string, get back a stub for that named object, no separate ID lookup step. Older codebases use the two-step version instead, env.RATE_LIMITER.idFromName(apiKey) followed by env.RATE_LIMITER.get(id), which does the same thing and is still fully supported.

And the config that wires it together:

wrangler.jsonc
{
"name": "rate-limiter-demo",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"durable_objects": {
"bindings": [
{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }
]
},
"exports": {
"RateLimiter": { "type": "durable-object", "storage": "sqlite" }
}
}

That exports block is the newer, declarative way to tell Wrangler this class is a SQLite-backed Durable Object. Older projects use an imperative migrations array instead:

wrangler.jsonc (legacy style)
{
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["RateLimiter"] }
]
}

Both work, but not together. Wrangler rejects a config with both fields set. Pick one and move on.

The Gotchas Nobody Puts On The Marketing Slide

The single-threaded guarantee has a real cost attached. No races, but also no free lunch. A hot Durable Object, meaning one ID that gets hammered by traffic (a rate limiter for your single busiest customer, a chat room everyone joined), is a bottleneck you built for yourself on purpose. There’s no scaling it out. If your traffic pattern is “one enormous object, everything else is fine,” you’ll feel that ceiling before you feel anything else.

Location matters too. A Durable Object comes into existence near wherever the first request for its ID landed, and stays there. If your audience is genuinely global and the object is genuinely one shared instance, users on the far side of the planet pay the round-trip latency to reach it. locationHint lets you nudge the initial placement (values like wnam, weur, apac), and jurisdictions (eu, us, fedramp) let you pin an object’s storage to a specific legal region for compliance, but neither turns one object into many.

Storage isn’t free either. Cloudflare rolled out usage-based billing for SQLite storage in 2026: stored data, rows read, rows written, all metered past a generous included allowance. It’s not the surprise line item on your bill, but it’s a real one now, not a rounding error you can ignore forever.

Debugging is the part that stings most in practice. You can’t SSH into a Durable Object. When something’s stuck in a bad state, your options are wrangler tail, logging everything you can think of ahead of time, and reasoning carefully about what your storage reads and writes actually did. It’s less like debugging a server and more like debugging a vending machine through the coin slot.

And there’s the migrations footgun: forget to add a new class to your migrations or exports block and you get a deploy-time error that reads like nonsense until you remember this step exists. Renaming a class needs its own migration entry too, or Wrangler treats it as an entirely new, empty class instead of the one you meant to rename.

Where This Beats A VPS, And Where It Doesn’t

For homelab-shaped problems, the sweet spot is anything that needs to be reachable from the internet, needs to coordinate state, and is idle almost all the time. A webhook receiver that dedupes retries so your automation doesn’t fire twice. A status page that pings a dozen self-hosted services and aggregates the results for anyone who checks. A small comment system tracking counts per post. A $5 VPS would happily run any of these, except the VPS bills you around the clock whether anyone’s looking or not, and you’re the one patching it at 2 AM when a CVE drops.

Where Durable Objects lose: anything that wants a real filesystem, anything doing sustained CPU work (video transcoding, model inference, batch processing), or anything that’s honestly just “I already have a Postgres box and it works fine.” Don’t migrate a working relational app onto Durable Objects because a blog post made it sound exciting. That’s hiring a forklift to carry a sandwich.

What Running Garrul In Production Actually Taught Me

I built Garrul, the comment system running under posts on this site, on Workers with D1, KV, and Turnstile. No Durable Objects in that stack. It works well for what it is: comments are a read-heavy, write-occasional workload, and D1 handles the relational shape (posts, comments, moderation state) without much fuss.

But building and running it in production is exactly what makes the case for Durable Objects click. The places where I’ve had to think hardest about race conditions, comment counts incrementing correctly under concurrent submissions, rate-limiting abusive requests without a shared coordination point, are precisely the places a Durable Object would have been the more natural tool, not a workaround bolted onto D1 and KV. If I were rebuilding the abuse-rate-limiting piece today, it’d be a Durable Object per IP or per post, not a counter I have to reason about carefully every time traffic spikes.

That’s the actual lesson. Workers plus KV plus D1 gets you most of the way for most apps. The last mile, the part where two requests must never step on each other, is a different shape of problem, and Durable Objects are the tool built for exactly that shape. Use them there, and leave everything else to the storage option that was already good at it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
gitui vs lazygit: Two Git TUIs Compared
Next Post
Sunshine + Moonlight: Roll Your Own Cloud Gaming

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts