Skip to content
DocstutorialDocumentation

Tutorial: scaling the chat app

Take the chat app from one Durable Object to many — shard by channel, put identities in a global table, and keep cross-shard reads off the hot path.

Last updated:

The realtime chat tutorial left you with a working app on a single Durable Object. That is the right shape for the first 80% of an app's life, and this chapter is about the other 20%: what to change when one DO is no longer enough, and how to tell when that moment has arrived.

Nothing here is speculative work you should do up front. Read it, then come back when the numbers say so.

Know when to act

A Durable Object holds up to 10 GB of SQLite and sustains roughly 1 000 requests per second. Lunora warns you well before either ceiling: when the __root__ DO crosses 1 GB (10% of the storage limit), the runtime logs once per DO lifetime.

[@lunora/do] __root__ Durable Object SQLite size is 1075000000 bytes
(>= 1 GiB, 10% of the 10 GiB per-DO ceiling). Plan a `.shardBy()` migration
before you hit the wall.

The margin is deliberate: a sharding migration needs runway, and 9 GB of it is plenty.

For the throughput side, lunora insights is the signal:

lunora insights

Write-conflict hot-spots mean writers are contending. Read OCC & atomicity before assuming that means sharding; a persistent conflict is often a handler conflicting with itself. But contention spread across many callers is exactly what a shard key fixes.

Step 1: shard the messages

Our chat has one DO holding every channel's messages. Channels are independent (nobody reads two channels in one transaction), which makes channelId a natural shard key. That is the whole test for a good key: what set of rows changes together?

The change is one modifier:

lunora/schema.ts
messages: defineTable({
    channelId: v.id("channels"),
    userId: v.id("users"),
    text: v.string(),
})
    .index("by_channel", ["channelId", "_creationTime"])
    .shardBy("channelId");

Run codegen and every ctx.db.messages.* call routes by channelId. A chat with 5 000 active channels now spreads across 5 000 Durable Objects, each with its own SQLite, CPU budget, and hibernation timer. Idle channels cost essentially nothing.

Your query does not change:

export const list = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) => {
    return ctx.db
        .query("messages")
        .withIndex("by_channel", (q) => q.eq("channelId", channelId))
        .order("desc")
        .take(50);
});

It reads one channel, so it addresses one shard, and it is as fast at 5 000 channels as it was at one.

Step 2: put identities in a global table

Sharding by channel raises an immediate question: where do users live? A user is not owned by a channel (they appear in many), so .shardBy("channelId") is wrong for them.

That is what .global() is for. The table moves to Cloudflare D1 instead of a DO, so it is readable from every shard:

lunora/schema.ts
users: defineTable({
    email: v.string(),
    name: v.string(),
})
    .global()
    .index("by_email", ["email"], { unique: true });

Reads can hit a regional replica, which makes them fast from anywhere; writes go through the primary. @lunora/d1 wraps the Sessions API so a read following your own write sees it.

The trade-off: a mutation touching both a sharded table and a .global() table is not writing both atomically. Treat the global write as a step that can fail on its own.

Everything you do not mark stays in __root__ (app config, feature flags, small per-app state), where it is cheap and strongly consistent.

Step 3: keep fan-out off the hot path

Here is where a sharded app gets slow if you are not paying attention. This query looks harmless:

// Every shard. Every time.
ctx.db.query("messages").order("desc").take(50);

It does not pin a shard, so it becomes a fan-out: the query coordinator dispatches to every shard and merges the results. At 5 000 channels that is 5 000 dispatches for 50 rows.

Fan-out is a legitimate tool: a nightly report, an admin view, an aggregate you compute on a schedule. It is not something to put behind a user-facing render. The fix is usually to make the shard explicit:

// One shard.
ctx.db
    .query("messages")
    .withIndex("by_channel", (q) => q.eq("channelId", channelId))
    .order("desc")
    .take(50);

When you genuinely need a cross-channel number ("unread count across all my channels"), precompute it. Keep a per-user counter updated by the mutation that writes the message, and read the counter instead of scanning.

Step 4: lock down shard access

Once you shard, the shard key becomes a client-supplied address. Without a gate, one tenant could ask for another tenant's Durable Object.

Lunora default-denies this: a request naming a shard other than the default, or requesting a fan-out, is rejected with 403 unless the worker opts in. A single-DO app never names a non-default shard, so this changes nothing until the day you shard, and on that day it fails closed rather than open.

Wire authorizeShard to gate it, deriving the answer from the verified identity rather than from anything the caller sent:

createWorker({
    shardDO: env.SHARD,
    authorizeShard: async ({ identity, shardKey }) => {
        if (!identity?.userId) {
            return false;
        }

        // The default shard is where unsharded tables live; it has no channel to be
        // a member of, so let a signed-in caller through rather than asking
        // `isMemberOfChannel` a question with no answer.
        if (shardKey === "__root__") {
            return true;
        }

        return isMemberOfChannel(identity.userId, shardKey);
    },
});

See Securing shard access for the details.

Step 5: migrate the data you already have

Adding .shardBy() to a table with rows in it is a data migration, not just a schema edit. The edit routes future rows by channelId; the rows already in __root__ stay there, unreadable and still consuming the bytes you sharded to reclaim.

Move them with an export/import round trip — dump the table, deploy the edit, then import the dump back so lunora import routes each row to its new shard:

lunora export --tables messages --out messages.ndjson --prod --url <url>
# stop writes
lunora codegen && lunora deploy --allow-schema-drift
lunora import messages.ndjson --prod --url <url> --yes --verify
# resume writes, then clear the table at shard __root__ in the Studio

The root DO keeps its own copies of every row it held before the edit. Your app can no longer see them, but the Studio's Data page can — set the shard key to __root__ and use Clear table. That step is off the critical path, so do it after writes resume.

Migrating a populated table has the full sequence. The table is unavailable for the length of that window, so take it deliberately — and take a snapshot first. Backups covers point-in-time recovery, which is exactly what you want behind a sharding migration.

lunora migrate is not the tool here. A defineMigration transform runs inside a single shard's Durable Object and can only rewrite the row it was handed, so it cannot move a row between DOs. Reach for it to backfill a column, not to re-home a table.

Checking your work

After the migration, confirm the new shape rather than assuming it:

lunora insights          # are conflicts gone? is latency down?
lunora doctor            # is the config still coherent?

The Studio's Advisors → Performance page shows the same signal with the fix linked, and the Data page's shard picker lets you look inside individual shards to confirm rows landed where you expected.

What you learned

  • Shard by what changes together, and not before the numbers say to.
  • Cross-tenant data goes .global(); everything unmarked stays in __root__.
  • A query that does not pin a shard is a fan-out, so keep it off user-facing paths.
  • Sharding turns the shard key into an untrusted input, so gate it.
  • Adding a shard key to a populated table is a data migration.