Durable Objects

Give an app stateful objects of its own, one per room, document or user, each with its own storage, alarms and WebSockets that hibernate, that keep their data from one release to the next.

A Durable Object is an object your app's code addresses by name: one instance of it anywhere in the world, handling one request at a time, with its own SQLite storage. It suits anything many requests share and have to agree on: a chat room, a document people edit together, a game lobby, one user's live feed, a rate limit. An object can hold WebSockets open while it sleeps (only a message wakes it, so an idle socket costs almost nothing) and set an alarm to wake itself later.

They're Cloudflare's Durable Objects, run on NoClick's edge, written as Cloudflare documents them. What NoClick adds is where they live: an app's objects belong to the app, not to one release, so every release reaches the same objects and their data.

Declare a class

Export a class extending DurableObject from your Worker's entry, bind it in your wrangler config, and add it in a migration:

// src/index.ts
import { DurableObject } from "cloudflare:workers";

export class Room extends DurableObject {
  async fetch(request: Request) {
    if (request.headers.get("Upgrade") === "websocket") {
      const [client, server] = Object.values(new WebSocketPair());
      this.ctx.acceptWebSocket(server);   // hibernates between messages
      return new Response(null, { status: 101, webSocket: client });
    }
    const seen = ((await this.ctx.storage.get<number>("seen")) ?? 0) + 1;
    await this.ctx.storage.put("seen", seen);
    return Response.json({ seen });
  }

  async webSocketMessage(ws: WebSocket, message: string) {
    for (const other of this.ctx.getWebSockets()) other.send(message);
  }
}

export default {
  async fetch(request: Request, env: { ROOM: DurableObjectNamespace }) {
    const room = new URL(request.url).searchParams.get("room") ?? "lobby";
    return env.ROOM.get(env.ROOM.idFromName(room)).fetch(request);
  },
};
// wrangler.jsonc
{
  "main": "src/index.ts",
  "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}

Deploy as usual. The build reads both keys, and your code reaches the class as env.ROOM.

  • SQLite only. A class goes in new_sqlite_classes. Key-value classes (new_classes) are refused at build, naming the fix.
  • Your own classes only. A binding with script_name (another Worker's class) is refused: an app's objects are its own.
  • Keep every migration. A migration list is the class's history. A new class gets a migration of its own ({"tag": "v2", "new_sqlite_classes": ["Lobby"]}), and a rename (renamed_classes) moves a class's objects and data to its new name.

Releases keep the data

Each environment of an app (production, and each preview branch) has its objects in one place that releases come and go around. A new release reaches the same objects with the data they hold, and retiring an old release never touches them. A preview branch starts with objects of its own, empty, and they go when the preview does.

The objects run the code of the release the environment serves. When a release goes live, by a deploy, a promote or a rollback, its classes' code goes to the objects first, and only then does traffic move. An object already awake keeps the code it started with until it next starts, usually within seconds. So two releases briefly see objects on the newer code: keep a class's methods compatible from one release to the next, as you would a database's schema. A hibernated WebSocket stays open across a release, and its next message runs the new code.

See them

app = noclick.Project.from_name("chat")
for room in app.durable_objects():
    print(room.name, room.environment, room.objects_count, room.stored_bytes, room.status)

page = app.durable_objects()[0].objects()
print([obj.id for obj in page.data if obj.has_stored_data])

Each class says how many objects it has (objects_more when there were more than were counted) and what they store, as the host last reported it. A new object is listed and counted a few minutes after it's made, and what it stores is reported about 20 minutes after it's written. A figure the host couldn't report is named in the list's unread and left empty, never shown as zero. Nothing outside your app's code reads what an object stores.

Delete a class

Deleting a class destroys every one of its objects' data, so it's never done by a release alone, and it goes to the trash first:

room = next(c for c in app.durable_objects() if c.name == "OldRoom")
item = room.delete()
  • It asks first. Like any deletion that stops what uses it, it's previewed (how many objects, what they store, whether the live release still uses the class) and, from your code, an agent or an MCP connection, held for the account's owner, who approves it from the link NoClick sends them (noclick.ApprovalRequired).
  • It's parked, not gone. For 30 days the class keeps its objects and their data, while every request, alarm and socket message reaching it is refused with a message saying it was deleted. An alarm it had is kept, and fires within a day of the class coming back.
  • Restore it from the trash (item.restore()), or undo the delete, while the release you serve still exports the class: its objects answer again with their data.
  • Then deploy the release that deletes it. Remove the class from your code and add {"tag": "v3", "deleted_classes": ["OldRoom"]} to your migrations. A release that deletes a class that isn't in the trash is refused, naming how to delete it.
  • After 30 days the class and its objects' data are deleted for good. That waits while the release you serve still exports the class: deploy the release that deletes it, and the class goes at the next pass.

A preview's class is deleted the same way, naming its branch (DELETE /v1/projects/{id}/durable_objects/{class}?branch=<branch>). Its objects still go with the preview: if the preview ends first (idle for 72 hours, its branch deleted, or the account's limit of previews reached), the class goes with it, before its 30 days.

Limits

  • An object has 128 MB of memory and up to 10 GB of SQLite storage, and handles one request at a time (roughly a thousand a second): spread a busy room across several objects.
  • An object lives near where it was first reached, which is where your app's code runs: the app's region (us-east, us-west, eu or ap), chosen when you make the app and fixed after. Requests from far away travel to it.
  • Rolling back to a release from before a class was added is refused while the class has objects: deleting it first is what lets that release serve.

Self-hosted

Durable Objects run only on NoClick's hosted edge. A self-hosted NoClick refuses a build that declares them; run them locally with wrangler dev.