Distributed systems
Part 5 of 6 · Durable ObjectsDurable Objects Scaling - Sharding by ID, Hot Objects, Location Hints, Limits & Cost
Scaling: per-object throughput soft limits, sharding by ID, hot objects and sharded counters, location hints and jurisdictions, cold starts and eviction, pricing; real rate limiter, lease with fencing token and counter run under wrangler dev; shard sizing and global-vs-per-key simulations.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What is the per-object throughput limit?
Answer
A soft limit around 1,000 requests per second, and about 200 to 500 when every request writes storage.
L2
How do you scale past one object?
Answer
Spread load across many IDs, one per user, room, tenant or key.
L3
How do you size shards for a hot counter?
Answer
Peak write rate divided by per-object capacity, with headroom.
L4
Should callers retry overloaded errors?
Answer
No. Retrying deepens the overload. Shed load or shard.
L5
How do you keep EU data in the EU?
Answer
Create IDs from a jurisdiction such as env.NS.jurisdiction("eu").
L6
Why does a lease need a fencing token?
Answer
A paused holder can act after its lease expired, so downstream systems must reject older tokens.
L7
What drives cost?
Answer
Requests and wall-clock duration while the object is awake and cannot hibernate.
Failure modes
A global singleton returns overloaded errors
All traffic queues on one thread, so most requests fail at launch-day load.
Objects placed far from users
Pre-creating objects from a script puts them near the script, not the users, and they do not move.
A stale lease holder writes after expiry
Without fencing tokens downstream, a paused client corrupts data after a new holder takes over.
Misconceptions
A bigger plan makes one object faster.
Objects scale out. One object is still one thread.
Location hints are guarantees.
Hints are best effort and honored only on the first get. Jurisdictions are the hard guarantee.
A lease lock in a Durable Object removes the need for fencing.
The object cannot stop a paused client from acting on a stale belief.
Interviewer traps
Building one global rate limiter object for all keys.
Use one object per key and shard any global budget.
Retrying every error from a hot object.
Retry only retryable errors on idempotent calls, never overloaded ones.
Design scenario
Same prompt for every reader.
Requirements
Exact per-key limits, a like count that survives the spike, and EU tenants kept in the EU.
Failure assumptions
- A few keys carry most traffic.
- Callers retry aggressively.
- Objects are created on the first request after launch.
Constraints
- No Redis cluster.
- Reads of the like count can be a few seconds stale.
Prompt
Design per-API-key rate limiting and a global like counter for a launch expecting 60,000 requests per second.
API
What do callers see on 429, and which Retry-After do they get?
Data
How many counter shards, and how is the rollup refreshed?
Architecture
Where do jurisdictions and location hints apply, and how do you warm placement?
Overview
Durable Objects scale out, not up. Each object is one thread with a soft limit of about 1,000 requests per second (the docs' FAQ), and more like 200 to 500 per second when every request writes storage (the Rules page). You can have an unlimited number of objects. So scaling means choosing an ID scheme that spreads load: one object per user, room, tenant or document. When a single logical thing is hotter than one object can handle, you split it into N shard objects and fan reads back in. You also choose where an object lives (location hints, jurisdictions) and pay per request plus per GB-second of wall time while it is awake and not eligible to hibernate.
How the code was checked: The real Durable Object TypeScript on this page type-checks with
tsc --strictagainst@cloudflare/workers-typesand was run locally underwrangler dev4.148.0 (workerd 2026-10-06) and the@cloudflare/vitest-plugintest pool. Blocks labeled simulation are sandbox models of the semantics, not Cloudflare code. Limits and prices are quoted from Cloudflare's docs as of 2026-10-07.
Why this matters
- Interviews: "Your global counter or rate limiter is a single Durable Object. What happens at 50k requests per second?" You need the overload error, the sharding math, and the read-side cost of sharding.
- Production: ID design is the one decision you cannot cheaply change later, because data lives inside objects keyed by those IDs.
Sharding by ID (step-labeled)
Decisions
- 1
1. Request with user, tenant or room key
- next2. Worker builds the ID string, e.g. tenant:acme
- 2
2. Worker builds the ID string, e.g. tenant:acme
- next3. Is one entity hotter than one object can take?
- ?
3. Is one entity hotter than one object can take?
- no4a. getByName(tenant:acme) handles it alone
- yes4b. Pick shard: name plus random or hashed suffix 0..N-1
- 4
4a. getByName(tenant:acme) handles it alone
- F1. Traffic spike on one keyOverloaded error: requests queued too long or too many
- 5
4b. Pick shard: name plus random or hashed suffix 0..N-1
- next5. Write goes to exactly one shard object
- 6
5. Write goes to exactly one shard object
- next6. Read fans in: Promise.all over N shards, or a rollup object
- 7
6. Read fans in: Promise.all over N shards, or a rollup object
- next7. Cache the total briefly if reads are frequent
- 8
7. Cache the total briefly if reads are frequent
- 9
Overloaded error: requests queued too long or too many
- related4b. Pick shard: name plus random or hashed suffix 0..N-1
Lesson map
Durable Objects Scaling - Sharding by ID, Hot Objects, Location Hints, Limits & Cost
Scaling: per-object throughput soft limits, sharding by ID, hot objects and sharded counters, location hints and jurisdictions, cold starts and eviction, pricing; real rate limiter, lease with fencing token and counter run under wrangler dev; shard sizing and global-vs-per-key simulations.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB r["1. Request with user, tenant or room key"] k["2. Worker builds the ID string, e.g. tenant:acme"] q["3. Is one entity hotter than one object can take?"] one["4a. getByName(tenant:acme) handles it alone"] sh["4b. Pick shard: name plus random or hashed suffix 0..N-1"] w["5. Write goes to exactly one shard object"] rd["6. Read fans in: Promise.all over N shards, or a rollup object"] c["7. Cache the total briefly if reads are frequent"] ov["Overloaded error: requests queued too long or too many"] r -->|continues| k k -->|continues| q q -->|no| one q -->|yes| sh sh -->|continues| w w -->|continues| rd rd -->|continues| c one -->|F1. Traffic spike on one key| ov ov -->|continues| sh
| ID scheme | Example | Scales with | Watch out for |
|---|---|---|---|
| Per user | user:123 | Users | Cross-user features need fan-out |
| Per room or document | room:general | Rooms | A celebrity room is a hot object |
| Per tenant | tenant:acme | Tenants | One whale tenant |
| Per entity plus shard | likes:post9:shard3 | Shards you add | Reads cost N calls |
| Parent and children | game:lobby -> match:77 | Children | Keep the parent light (index only) |
| Global singleton | global | Nothing | The docs call this an anti-pattern |
One global object or one object per key?
Prefer
One object per key, sharded when hot
Spread load across IDs and split any single key that outgrows one thread.
- 50 per-key objects served 30,000 requests with no overload errors.
- Shard count comes from peak rate over per-object capacity.
- Global budgets are divided across shards.
Alternative
A single global object
Exact and simple, until one thread is the whole system.
- The singleton served 8,000 and rejected 20,000.
- Queue delay climbed to 2.5 s.
- Retries made the overload worse.
From a hot key to a sharded design
Diagram 1 condensed. The simulations size the shards and the real runs show the building blocks.
- 1
Pick the ID scheme
One object per user, room, tenant or key. - 2
Measure the hottest key
Compare its peak rate with per-object capacity. - 3
Shard what is too hot
Write to a random shard, read by fan-in or a rollup. - 4
Never retry overload
Overloaded errors mean shed or shard, not retry.
Shard sizing (simulation, not Cloudflare code), planned at a conservative 400 requests per second per object:
# SIMULATION (not Cloudflare code): sizing shards for a hot counter.
# Per-object capacity uses the docs' guidance: soft limit ~1,000 req/s for simple work,
# ~200-500 req/s when each request writes storage. We plan with a conservative 400 req/s.
import math, random
PER_OBJECT = 400
for name, rps in [("per-user cart", 3), ("one chat room", 150), ("global like counter", 12_000), ("launch-day signup counter", 60_000)]:
shards = max(1, math.ceil(rps / PER_OBJECT * 1.5)) # 1.5x headroom for skew and bursts
print(f"{name:<26} {rps:>7,} req/s -> {shards:>4} object(s)")
# Random shard choice is not perfectly even: check the hottest shard against capacity.
random.seed(7)
rps, shards = 12_000, 45
counts = [0] * shards
for _ in range(rps): # one second of traffic
counts[random.randrange(shards)] += 1
print(f"12,000 req/s over {shards} shards: mean={rps / shards:.0f} hottest={max(counts)} coldest={min(counts)} (cap {PER_OBJECT})")
# Reads must fan in: one read = 45 RPCs. Cache the total for a few seconds if reads are frequent.
print(f"read cost: 1 total = {shards} RPCs; at 50 reads/s that is {50 * shards:,} RPCs/s -> cache it or roll up with an alarm")Output (simulation):
per-user cart 3 req/s -> 1 object(s)
one chat room 150 req/s -> 1 object(s)
global like counter 12,000 req/s -> 45 object(s)
launch-day signup counter 60,000 req/s -> 225 object(s)
12,000 req/s over 45 shards: mean=267 hottest=313 coldest=234 (cap 400)
read cost: 1 total = 45 RPCs; at 50 reads/s that is 2,250 RPCs/s -> cache it or roll up with an alarmWhat a single global object does under load
A single-threaded object serves at a fixed rate. Anything above that queues, and past the runtime's limits callers get errors such as "Durable Object is overloaded. Too many requests queued." or "Requests queued for too long." Those errors carry .overloaded = true and must not be retried. Simulation (not Cloudflare code):
// SIMULATION (not Cloudflare code): one global rate-limiter object vs one object per API key.
// Each object is single-threaded; we model it as a queue served at CAP req/s. Values are example numbers.
const CAP = 800; // what one object can serve per second for this workload
const keys = 50, perKeyRps = 60; // 50 tenants x 60 req/s = 3,000 req/s total
const QUEUE_LIMIT = 2_000; // beyond this the runtime answers "Durable Object is overloaded"
function simulate(objects: number, seconds: number) {
const arrivalsPerObject = (keys * perKeyRps) / objects;
let queue = 0, overloaded = 0, served = 0;
for (let s = 0; s < seconds; s++) {
queue += arrivalsPerObject;
const done = Math.min(queue, CAP); served += done; queue -= done;
if (queue > QUEUE_LIMIT) { overloaded += queue - QUEUE_LIMIT; queue = QUEUE_LIMIT; }
}
const waitSec = queue / CAP;
return { objects, perObjectRps: arrivalsPerObject, served: Math.round(served * objects), overloadedErrors: Math.round(overloaded * objects), queueDelayMs: Math.round(waitSec * 1000) };
}
console.log("global singleton :", JSON.stringify(simulate(1, 10)));
console.log("per-key objects :", JSON.stringify(simulate(keys, 10)));Output (simulation):
global singleton : {"objects":1,"perObjectRps":3000,"served":8000,"overloadedErrors":20000,"queueDelayMs":2500}
per-key objects : {"objects":50,"perObjectRps":60,"served":30000,"overloadedErrors":0,"queueDelayMs":0}Expectedglobal singleton : {"objects":1,"perObjectRps":3000,"served":8000,"overloadedErrors":20000,"queueDelayMs":2500} per-key objects : {"objects":50,"perObjectRps":60,"served":30000,"overloadedErrors":0,"queueDelayMs":0}
Press Run. Snippets must be self-contained — no network, files, or native modules.
Worked example 1: a per-key rate limiter
One object per API key or tenant holds a token bucket. Because the whole decision is synchronous inside the object, there is no read-modify-write race, and the bucket is persisted so an eviction cannot hand the client a fresh full burst.
// RateLimiter: one object per (tenant or API key). Token bucket persisted in the object's SQLite.
import { DurableObject } from "cloudflare:workers";
import type { Env } from "./index";
export type Decision = { allowed: boolean; remaining: number; retryAfterMs: number };
export class RateLimiter extends DurableObject<Env> {
private tokens = -1; // in-memory copy; -1 means "not loaded yet" (fresh instance or after eviction)
private updated = 0;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS bucket(id INTEGER PRIMARY KEY CHECK(id = 1), tokens REAL, updated INTEGER)");
const row = ctx.storage.sql.exec("SELECT tokens, updated FROM bucket").toArray()[0];
if (row) { this.tokens = row.tokens as number; this.updated = row.updated as number; }
});
}
// capacity = burst size, refillPerSec = sustained rate. Whole method is synchronous => atomic per key.
take(capacity: number, refillPerSec: number, cost = 1): Decision {
const now = Date.now();
if (this.tokens < 0) { this.tokens = capacity; this.updated = now; }
// Refill based on elapsed time, capped at capacity.
this.tokens = Math.min(capacity, this.tokens + ((now - this.updated) / 1000) * refillPerSec);
this.updated = now;
let allowed = false;
if (this.tokens >= cost) { this.tokens -= cost; allowed = true; }
// Persist so an eviction cannot hand a client a fresh full bucket. Output gate holds the reply until durable.
this.ctx.storage.sql.exec(
"INSERT INTO bucket(id, tokens, updated) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET tokens = excluded.tokens, updated = excluded.updated",
this.tokens, this.updated);
const deficit = Math.max(0, cost - this.tokens);
return { allowed, remaining: Math.floor(this.tokens), retryAfterMs: allowed ? 0 : Math.ceil((deficit / refillPerSec) * 1000) };
}
}Real run (burst 5, refill 1 token per second):
Output (captured from the real run):
7 rapid calls (status:remaining:retryAfterMs) -> 200:4:0 200:3:0 200:2:0 200:1:0 200:0:0 429:0:946 429:0:940
after 2.1s -> 200 {"allowed":true,"remaining":1,"retryAfterMs":0}| Rate limiter design | Accuracy | Latency | Ops cost | If you pick it instead |
|---|---|---|---|---|
| DO per key (this) | Exact per key | One hop to the key's object | None to run | (baseline) |
| Redis plus Lua script | Exact | One hop to Redis | You run Redis | Same accuracy, plus infrastructure |
| Workers KV counter | Wrong under concurrency | Low | None | Lost updates let bursts through |
| In-memory per Worker instance | Per instance only | Zero | None | N instances means N times the limit |
| One global DO for all keys | Exact | Queues at scale | None | Overloaded errors at a few hundred to 1,000 req/s |
For a global limit across all keys, shard the budget (each of N objects gets limit/N) or accept a slightly stale global view.
Worked example 2: a lease lock with fencing tokens
A lock is just an object named after the resource. It grants time-bounded leases, and every grant carries a strictly increasing fencing token. A holder that paused past its lease (GC pause, network partition) cannot release the new holder's lease, and downstream systems should reject writes carrying an older token. An alarm clears expired leases even if nobody calls the object again.
// LeaseLock: one object per lock name. Leases expire; every grant carries a monotonically increasing fencing token.
import { DurableObject } from "cloudflare:workers";
import type { Env } from "./index";
type Lease = { owner: string; token: number; expiresAt: number };
export class LeaseLock extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS lease(id INTEGER PRIMARY KEY CHECK(id = 1),
owner TEXT, token INTEGER NOT NULL, expires_at INTEGER NOT NULL)`);
});
}
private current(): Lease | null {
const r = this.ctx.storage.sql.exec("SELECT owner, token, expires_at FROM lease").toArray()[0];
return r ? { owner: r.owner as string, token: r.token as number, expiresAt: r.expires_at as number } : null;
}
// acquire is synchronous until the setAlarm call, so check-then-grant cannot interleave with another acquire.
async acquire(owner: string, ttlMs: number): Promise<{ ok: boolean; token?: number; holder?: string; expiresAt: number }> {
const now = Date.now();
const cur = this.current();
if (cur && cur.owner && cur.expiresAt > now && cur.owner !== owner) {
return { ok: false, holder: cur.owner, expiresAt: cur.expiresAt };
}
const token = (cur?.token ?? 0) + 1; // fencing token never goes backwards, even across expiry
const expiresAt = now + ttlMs;
this.ctx.storage.sql.exec(
"INSERT INTO lease(id, owner, token, expires_at) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, token = excluded.token, expires_at = excluded.expires_at",
owner, token, expiresAt);
await this.ctx.storage.setAlarm(expiresAt); // wake up to clear the lease even if nobody calls us
return { ok: true, token, expiresAt };
}
release(owner: string, token: number): boolean {
const cur = this.current();
if (!cur || cur.owner !== owner || cur.token !== token) return false; // stale holder cannot release a newer lease
this.ctx.storage.sql.exec("UPDATE lease SET owner = NULL, expires_at = 0 WHERE id = 1");
return true;
}
// Downstream services should reject writes whose token is lower than the highest they have seen.
async alarm(): Promise<void> {
const cur = this.current();
if (cur && cur.owner && cur.expiresAt <= Date.now()) {
this.ctx.storage.sql.exec("UPDATE lease SET owner = NULL WHERE id = 1");
} else if (cur && cur.owner) {
await this.ctx.storage.setAlarm(cur.expiresAt); // lease was renewed; re-arm for the new expiry
}
}
}Real run:
Output (captured from the real run):
A acquire -> {"ok":true,"token":1}
B acquire while A holds -> {"ok":false,"holder":"worker-a"}
B acquire after A expired -> {"ok":true,"token":2}
A (stale, token 1) tries release -> {"released":false}Compared with Redis SET NX PX or Redlock: the object gives you a single serialized authority with durable state and a built-in timer, so you do not need a quorum of Redis nodes. You still need fencing tokens, because the object cannot stop a paused client from acting on a stale belief.
Worked example 3: a sharded counter
The counter object from the hub, with two Worker helpers: writes pick a random shard, reads sum all shards. The real run spread 400 increments across 8 shards and the fan-in returned exactly 400:
Output (captured from the real run):
inc -> { value: 1 }
inc -> { value: 2 }
inc -> { value: 3 }
after 200 concurrent incs -> { value: 203 }
sharded total -> 400 perShard -> [43,52,64,41,40,49,54,57]Read cost grows with N. If you read often, keep a rollup object that an alarm refreshes every few seconds, or cache the sum in the Worker.
Location hints and jurisdictions
- Default placement: near the first
get()request. The docs warn that pre-creating objects from a script, or from an unrepresentative first request, can put them far from real users. locationHint(wnam,enam,sam,weur,eeur,apac,apac-ne,apac-se,oc,afr,me) is honored only on the firstget()and is best effort.sam,afrandmecurrently spawn in a nearby supported region.- Jurisdictions (
eu,us,fedramp) are hard guarantees about where the object runs and stores data:env.NS.jurisdiction("eu").idFromName(name). The same name gives a different ID in each jurisdiction. Workers anywhere can still call the object, and the object ID itself is logged outside the jurisdiction for billing and debugging. - Objects do not move after creation today. A user who relocates keeps talking to their original region.
Decisions
- 1
1. Legal requirement on where data lives?
- yesUse jurisdiction(eu, us or fedramp) subnamespace
- no2. Do most users of this object sit in one region?
- 2
Use jurisdiction(eu, us or fedramp) subnamespace
- ?
2. Do most users of this object sit in one region?
- yes, and first caller is representativeDefault: created near first request
- yes, but first caller is notPass locationHint on first get()
- no, global audience3. Can you split by region?
- 4
Default: created near first request
- 5
Pass locationHint on first get()
- ?
3. Can you split by region?
- yesOne object per region plus async merge
- noAccept one home region, put read-only data in KV or cache
- 7
One object per region plus async merge
- 8
Accept one home region, put read-only data in KV or cache
Cold starts and eviction
- First call to a brand-new named object includes a global uniqueness check. Later lookups are cached worldwide.
- An idle object hibernates after about 10 seconds if eligible. If it is not eligible (pending timers or I/O, standard WebSockets), it is evicted after 70 to 140 seconds of inactivity, per the lifecycle docs.
- Waking runs the constructor again. Keep it fast, and make lazy loads cheap with the storage cache.
- Pending I/O and
waitUntilpromises keep an object in memory for up to 15 minutes each, and that time is billed.
Cost model, from the pricing page (Workers Paid)
| Dimension | Included | Price |
|---|---|---|
| Requests (HTTP, each RPC session, alarm invocations, WebSocket messages at 20:1) | 1 million per month | $0.15 per million |
| Duration (wall clock while active or idle-but-not-hibernatable, billed at 128 MB) | 400,000 GB-s per month | $12.50 per million GB-s |
| SQLite rows read | 25 billion per month | $0.001 per million |
SQLite rows written (each setAlarm counts as one) | 50 million per month | $1.00 per million |
| SQLite stored data | 5 GB-month | $0.20 per GB-month |
Billable usage is rounded up to the next million, and there is the $5 per month Workers Paid minimum. The Free plan includes 100,000 requests and 13,000 GB-s per day. The biggest cost lever is duration: hibernate WebSockets, avoid setInterval, and do not keep objects awake with long outbound calls.
What happens if you choose otherwise
- One object per tenant when one tenant is 100x the rest: that tenant's object overloads. Shard the whale by sub-key.
- Random IDs (
newUniqueId) without storing them: you can never find the object again. - Very fine-grained IDs (one object per request): no coordination benefit, and you pay a request plus a cold start each time.
- Global replicated state with CRDTs across many objects: possible (the Cloudflare SQLite post suggests it for extreme cases), but you trade strong ordering for convergence.
Pitfalls
- Retrying overloaded errors makes them worse. Back off, shed load, or shard.
- Stub creation for many new objects at once can return "Your account is generating too much load on Durable Objects. Please back off and try again later." Spread the lookups.
- Each request may make at most 6 simultaneous outgoing connections, the same as Workers.
- CPU per invocation defaults to 30 seconds, configurable up to 5 minutes with
limits.cpu_ms.
Interview Q&A
Your like counter's object is overloaded. What do you do?
Answer
Shard it: writes go to likes:post:shardK with K random in 0..N-1. Reads sum the shards or read a rollup refreshed by an alarm. Size N from peak write rate over per-object capacity with headroom, and never retry overloaded errors.
How do you build a distributed lock with Durable Objects?
Answer
One object per resource grants leases with a TTL and a monotonically increasing fencing token, persisted in SQLite. An alarm expires leases, release checks owner and token, and downstream systems reject stale tokens.
How do you keep EU user data in the EU?
Answer
Create IDs from env.NS.jurisdiction("eu"). The object then only runs and stores data in the EU, while Workers anywhere can still call it.
What drives cost?
Answer
Requests and, above all, wall-clock duration while the object is awake and cannot hibernate. Hibernating WebSockets and short-lived objects keep duration low. SQLite rows are billed like D1.
Where should the object for a user who moved from New York to Berlin live?
Answer
It stays where it was created, since objects do not relocate today. If latency matters, migrate data into a new object (for example a new ID with a region suffix) and update the pointer.
How does the Durable Object rate limiter compare with Redis plus Lua?
Answer
Both are exact per key. The Durable Object needs no infrastructure to run, while Redis adds a cluster you operate.
How long does an idle object stay awake?
Answer
It hibernates after about 10 seconds if eligible, otherwise it is evicted after 70 to 140 seconds of inactivity.
Why does a read of a sharded counter get more expensive?
Answer
Every read fans in across N shards. Keep a rollup refreshed by an alarm or cache the sum.
Check yourself
Pick the hottest key in a system you know. Estimate its peak requests per second, compute a shard count with headroom, and decide how reads fan back in.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Token Bucket vs Leaky Bucket vs Sliding Window, Distributed Rate Limits Across Gateways, Redis + Lua Atomic Rate Limiters, Rate Limiter for an LLM Gateway - LLD Spec & Concepts, Rate Limiter - Concurrency, Retry-After & Composition, Distributed Locks — Correctness, Leases & Fencing Tokens, Lease Renewal, Clock Skew & Heartbeat Failure Modes, Shard Keys — Hotspots, Fan-out Queries & Cardinality, Multi-AZ vs Multi-Region - Blast Radius, Cell Architecture & Static Stability.
Go Deeper
- Durable Objects limits (soft limit per object, overload)
- Rules of Durable Objects: the atom of coordination, throughput, deterministic IDs
- Data location: location hints and jurisdictions
- Durable Objects pricing
- Troubleshooting overload errors
- Where Durable Objects live
- Martin Kleppmann: How to do distributed locking (fencing tokens)