| name | lunora-performance-audit |
| description | Diagnoses and fixes Lunora performance problems — full-table scans, missing indexes, OCC write conflicts, oversized subscriptions, and sharding/`.global()` scaling. Use when queries are slow, mutations conflict, or `@lunora/advisor` flags a table. |
Lunora Performance Audit
Systematically diagnose Lunora performance issues, route to the right fix, and
apply it across sibling functions consistently.
When to Use
- Queries feel slow or read far more rows than they return.
- Mutations retry or conflict under load (OCC).
- Subscriptions fan out updates too broadly or re-run too often.
- The Lunora Studio Advisors tab or
@lunora/advisor flags a table.
When Not to Use
- Scale is small, traffic is modest, and there is no measured problem — prefer
simpler code. Do not introduce sharding, digests, or splits on speculation.
Core Workflow
- Scope one concrete user flow with a clear entry and exit.
- Trace every
ctx.db read and write in that flow.
- Route to the matching problem class below.
- Fix siblings consistently — every function touching the same table should
read it the same indexed way.
- Verify behavior is unchanged and the advisor finding clears.
Signal Gathering
Start with the static advisors — they need no traffic:
- Lunora Studio → Advisors tab surfaces
@lunora/advisor findings live in
dev.
@lunora/advisor runs static lints over defineSchema + discovered query
reads / insert writes. Relevant performance/schema rules:
filter-without-index — a query filters a table with no covering index.
unindexed-foreign-key — a relation/FK column has no index.
duplicate-index / empty-index — wasted or malformed indexes.
index-references-unknown-field, relation-references-unknown-field,
relation-references-unknown-table — broken index/relation definitions.
table-without-insert — a table is read but never written (often a
schema/typo signal).
Problem Class: Read Amplification (the common one)
Symptom: a query reads the whole table to return a few rows;
filter-without-index fires.
Fix: read through an index, not .filter(). Declare the index on the table
and use .withIndex() with an equality/range on the leading columns.
const mine = (await ctx.db.query("documents").collect()).filter((d) => d.orgId === orgId);
documents: defineTable({ orgId: v.string(), createdAt: v.number() }).index("by_org_created", ["orgId", "createdAt"]);
const mine = await ctx.db
.query("documents")
.withIndex("by_org_created", (q) => q.eq("orgId", orgId))
.collect();
Index columns are ordered: put equality columns first, then the range/sort
column. Fix every sibling query on the table the same way.
Problem Class: Write Conflicts (OCC)
Symptom: mutations on hot rows retry or fail under concurrency. ShardDO uses
optimistic concurrency control — concurrent writes to the same DO that touch
overlapping state conflict and retry.
Fixes, in order of preference:
- Narrow the write. Patch only the fields that changed; avoid read-modify-
write over rows another mutation also touches.
- Partition with
.shardBy(key). Move per-user / per-tenant / per-room
state into its own DO so writes for different keys never contend. This is the
primary horizontal-scale lever — most write contention is a single-DO
hotspot.
- Avoid unbounded counters/aggregates in the hot path. Accumulate in a
sharded/append shape and fold lazily rather than serializing every writer
through one row.
Problem Class: Subscription Cost
Symptom: a useQuery re-runs and re-pushes to many clients on unrelated
writes.
Fixes:
- Scope query args tightly so a subscription only depends on the rows it
renders — a query keyed by
orgId should not re-run for another org's write.
- Read through indexes (above) so the reactive dependency is the narrow
index range, not the whole table.
- ShardDO subscriptions are hibernated WebSockets — idle connections cost
nothing; the lever is how many rows each live query depends on, not raw
connection count.
Problem Class: Cross-Region Reads
Symptom: read-heavy, rarely-written data is slow for far-away users.
Fix: chain .global() on the table to replicate it to D1 for low-latency
cross-region reads (with read-your-writes via the Sessions API). Reserve it for
read-mostly tables — .global() adds the D1 migration flow (see the
lunora-migration-helper skill) and write-path cost.
.shardBy(key) vs .global() — choose one per table
.shardBy(key): partitions a table across Durable Objects by key — scales
writes (e.g. messages per room). Reads are per-shard.
.global(): replicates a table to D1 — scales cross-region reads with
read-your-writes (e.g. a mostly-read catalog).
- They are not combined on the same table; the default (neither) is a single
root-scoped ShardDO.
Guardrails
- Prefer simpler code when scale is small or the signal is weak.
- Do not recommend structural changes (digest tables, document splitting,
sharding) without a measured signal or a known hot path.
- When you change how one function reads/writes a table, change its siblings to
match — half-migrated access patterns are their own bug.
Checklist