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 insightsWrite-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:
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:
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 StudioThe 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.