Limits
Measured cost, practical ceilings, and what the numbers do not cover.
Two sets of numbers, measured two ways. Engine figures come from
bun run bench and describe algorithmic cost in process. Deployment figures
come from bun run load against a real Cloudflare deployment.
Engine figures exclude storage latency, network round trips, WebSocket delivery, and isolate contention. Deployment figures include all of it and are dominated by round trip. In both cases the durable part is how cost scales between rows, not the absolute milliseconds.
Practical ceilings
| Dimension | Comfortable | Investigate beyond |
|---|---|---|
| Woken subscribers per write | ≤ 100 | 500 |
| Live subscriptions per tenant | ≤ 500 | 1,000 |
| Rows per subscribed query result | ≤ 1,000 | 5,000 |
| Unindexed subscribed queries | 0 | any |
Invalidation precision (engine)
The single largest lever in the system.
| Query shape | Subscribers | Woken per write | Writes/sec |
|---|---|---|---|
| indexed prefix | 64 | 8 | 3,624 |
| full table scan | 64 | 64 | 130 |
| indexed prefix | 256 | 32 | 983 |
| full table scan | 256 | 256 | 33 |
27× at 64 subscribers, 31× at 256. The difference is not recomputation count — that is shared per query shape — it is that every woken subscriber must be diffed.
Fan-out (engine)
| Subscribers | Writes/sec | p50 | p95 |
|---|---|---|---|
| 1 | 13,659 | 0.074 ms | 0.092 ms |
| 10 | 9,629 | 0.099 ms | 0.140 ms |
| 100 | 2,282 | 0.409 ms | 0.711 ms |
| 500 | 577 | 1.758 ms | 2.368 ms |
Patch size
| Rows in result | One-row patch | Full snapshot | Saving |
|---|---|---|---|
| 10 | 163 B | 4,412 B | 27× |
| 100 | 164 B | 10,138 B | 62× |
| 1,000 | 165 B | 69,594 B | 422× |
Patch size is independent of result size, which is why preserving resume across
reconnects matters. Recomputation still scales with result size, so prefer
take().
Reconnect storm (engine)
What a Durable Object pays when it wakes and rebuilds subscriptions, before serving its first event.
| Subscriptions restored | Total (p50) |
|---|---|
| 50 | 1.5 ms |
| 200 | 5.5 ms |
| 500 | 16.1 ms |
Under load (deployment)
| Case | p50 |
|---|---|
| sequential mutation | 97 ms |
| per-write, 4 concurrent writers | 27 ms |
| per-write, 16 concurrent writers | 9.6 ms |
| fan-out to 1 subscriber | 95 ms |
| fan-out to 20 subscribers | 101 ms |
| reconnect, 20 clients at once | 21 ms |
Two relationships matter more than the figures.
Fan-out is flat from 1 to 20 subscribers. 95 ms to 101 ms is inside run-to-run noise: delivering to twenty browsers costs about what delivering to one costs, because the round trip is the bottleneck.
Concurrency is absorbed. Sixteen simultaneous writers cost 9.6 ms each rather than 100 ms each — a Durable Object pipelines even though it serializes its own events.
Tenants scale independently (deployment)
| Tenants writing at once | Per-write p50 |
|---|---|
| 1 | 141 ms |
| 4 | 55 ms |
| 16 | 14.9 ms |
Each tenant is its own Durable Object, so this is close to linear and nothing shared shows contention at this size. This is the number to re-check first if the architecture ever grows a shared component.
Waking a quiet tenant (deployment)
| p50 | |
|---|---|
| warm tenant, first read | 167 ms |
| after 15 s idle, first read | 190 ms |
An upper bound rather than a measurement: whether the platform actually evicted the object is its decision, not something a client can force.
Sustained traffic (deployment)
133 write-and-read cycles over 45 seconds, ending with 133 rows: +13.9% latency drift from the first third of the run to the last.
That drift is attributable — rows accumulated throughout and the read was unbounded, so it genuinely got bigger. A leak would show drift with no such explanation, which is why drift is reported rather than an average.
Not measured
Throughput against real Durable Object SQLite in isolation, concurrent writers contending inside one isolate, WebSocket back-pressure, long-term storage growth, and anything soaked for hours.
→ Load testing to measure your own deployment.