State and background work

Coordinate with Durable Objects

Recognize when one globally unique stateful object is the right tool for per-user coordination, locks, counters, or real-time rooms.

Everything we built so far runs in many isolates at once, and none of them know about the others. That’s fine until you need one place to agree. “Only three exports per user per hour” can’t be enforced from a KV counter, because two requests in two regions both read 2, both write 3, and the user gets four.

A Durable Object solves this. It’s a single instance with a unique ID, its own storage, and one important property: requests to the same ID are handled one at a time. There is no race, because there is no concurrency inside one object.

Declare the class

{
  "durable_objects": {
    "bindings": [{ "name": "EXPORT_LIMITER", "class_name": "ExportLimiter" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ExportLimiter"] }]
}

The migrations block tells Cloudflare a new class exists. Every new class needs an entry.

Write the object

The class extends DurableObject and exposes plain methods you call from the Worker:

import { DurableObject } from 'cloudflare:workers'

export class ExportLimiter extends DurableObject {
  async tryAcquire(): Promise<boolean> {
    const count = (await this.ctx.storage.get<number>('count')) ?? 0
    if (count >= 3) return false
    await this.ctx.storage.put('count', count + 1)
    return true
  }
}

Reading, checking, and writing happen without any other request getting in between. That’s the whole point. Resetting the counter every hour is a job for a Durable Object alarm, a timer the object sets on itself. Make that reset idempotent too, because alarms can fire again after a retry.

Pick the ID with care

The ID is the coordination boundary. Derive it from the user:

const id = c.env.EXPORT_LIMITER.idFromName(userId)
const allowed = await c.env.EXPORT_LIMITER.get(id).tryAcquire()
if (!allowed) return c.json({ error: 'Too many exports' }, 429)

idFromName always maps the same name to the same object. One object per user means one user’s requests wait on each other, and nobody else waits on them.

Two rules here. Authenticate first, then pick the ID from the internal user ID. Never from a header or a query string the client controls, or one user can drain another’s quota. And don’t put all traffic through one global ID. That object becomes a bottleneck and a single point of failure. Shard by user, by room, by account.

Serialized is not durable

The object can hibernate or restart at any time, and instance fields vanish when it does. Save the state that matters in this.ctx.storage before returning success, exactly as the example does. A this.count field would work in a test and lose its value in production.

Also remember that a network call after a storage write can still fail. Record enough state to resume or undo. Don’t hold an implicit lock while waiting on an external API.

Try it on your project: build the per-user export limiter above and call it from POST /api/exports before enqueueing the job. Fire four requests in a row. Three get 202, the fourth gets 429, every time, with no KV involved.

Lesson completed