Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/denoland/celld/llms.txt

Use this file to discover all available pages before exploring further.

In celld, a cell is a Durable Object — a named, stateful unit with a private SQLite database. Cells use the exact same JavaScript API as Cloudflare Durable Objects. You make one cell for each user, each document, each chat room, or each AI agent. A cell serves HTTP, holds WebSocket connections, sets alarms, and makes outbound connections.
Every acknowledged write is proven durable before the response returns (RPO=0). The loss of a node cannot lose an acknowledged write.

Defining a Durable Object

A Durable Object class exports a constructor that receives state and env, and a fetch method that handles incoming requests. Here is the complete counter example:
export class Counter {
  constructor(state, env) { this.state = state; }
  async fetch(request) {
    let n = (await this.state.storage.get("n")) ?? 0;
    n++;
    await this.state.storage.put("n", n);
    return new Response(JSON.stringify({ n, url: request.url }), { status: 200 });
  }
}

export default {
  async fetch(request, env) {
    const id = env.COUNTER.idFromName("room-42");
    return env.COUNTER.get(id).fetch(request);
  }
};
The wrangler.jsonc for this project declares the binding and migration:
{
  "name": "counter",
  "main": "index.js",
  "compatibility_date": "2026-01-01",
  "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}

Storage API

state.storage is backed by a private per-cell SQLite database. All storage operations are synchronous with respect to request handling — they do not interleave with other operations on the same cell.
MethodDescription
get(key)Read a value by key. Returns undefined if the key does not exist.
put(key, value)Write a value. The write is durable before the call resolves.
delete(key)Delete a key.
list(options?)List key-value pairs, with optional prefix, start, end, limit, and reverse.
deleteAll()Delete all keys.
getAlarm()Return the scheduled alarm time in milliseconds, or null.
setAlarm(scheduledTime)Schedule an alarm at a Unix timestamp in milliseconds.
deleteAlarm()Cancel the pending alarm.
transaction(fn)Run a callback inside an atomic transaction.
sqlAccess the underlying SQLite engine directly for SQL queries.

Single-writer guarantee

Each cell runs on one thread. Two requests to the same cell never run simultaneously. A second request can interleave only while the first awaits an asynchronous operation — and because storage operations are synchronous, they never interleave at all. The data in a cell therefore stays consistent without locks.
async fetch(request) {
  // These two operations are atomic from the cell's perspective.
  // No other request can observe the cell between get() and put().
  let n = (await this.state.storage.get("n")) ?? 0;
  n++;
  await this.state.storage.put("n", n);
  return new Response(String(n));
}

Cell lifecycle

A cell moves through four states:
1

Active

The cell is in memory and currently processing a request or alarm.
2

Idle

The cell is in memory but waiting. Celld removes an idle cell from memory after a short period.
3

Hibernated

The cell has been removed from memory but retains its hibernatable WebSocket clients and stays on its node. The constructor runs again on the next event — only the WebSocket connections and the node assignment distinguish hibernation from the inactive state.
4

Inactive

No node holds the cell. It exists only as an object in the fleet bucket, costing almost nothing. Every cell starts in this state.
Memory holds nothing across these transitions. The constructor runs again at each activation. Do not store ephemeral state in instance variables that you expect to survive a request.
export class Counter {
  constructor(state, env) {
    // This runs on every activation — do not assume it runs only once.
    this.state = state;
  }
}

Migrations

Celld uses durable_objects.migrations in wrangler.jsonc to track which classes have SQLite storage. Use new_sqlite_classes to mark a class as SQLite-backed on its first deployment:
{
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Counter"] }
  ]
}
Each migration tag must be unique. Celld applies migrations in order and records which tags have run. Do not remove or reorder existing migration entries.

Bindings

The Worker accesses a Durable Object namespace through env. Use idFromName to derive a deterministic ID from a string key, get to obtain a stub, and fetch to send a request:
export default {
  async fetch(request, env) {
    // Derive a stable ID from a name — same name always routes to the same cell.
    const id = env.COUNTER.idFromName("room-42");

    // Get a stub for the cell.
    const stub = env.COUNTER.get(id);

    // Forward the request to the cell.
    return stub.fetch(request);
  }
};
idFromName is deterministic: the same name always maps to the same cell, across nodes and across restarts. Route by user ID, room name, document ID, or any other natural key.

Alarms

A cell can schedule an alarm that fires at a specific time. Use setAlarm to arm it and implement an alarm method on the class to handle it:
export class Agent {
  constructor(state, env) { this.state = state; }

  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/arm") {
      await this.state.storage.setAlarm(Date.now() + 2000);
      return new Response(JSON.stringify({
        armed: true,
        at: await this.state.storage.getAlarm()
      }));
    }
    const fires = (await this.state.storage.get("fires")) ?? 0;
    return new Response(JSON.stringify({
      fires,
      pendingAlarm: await this.state.storage.getAlarm()
    }));
  }

  async alarm(info) {
    const fires = (await this.state.storage.get("fires")) ?? 0;
    await this.state.storage.put("fires", fires + 1);
    await this.state.storage.put("lastRetry", info.retryCount);
  }
}
Alarms are durable: celld retries a failed alarm and passes info.retryCount so the handler can distinguish retries from first fires.

Build docs developers (and LLMs) love