---
title: 'Durable State Objects'
source: 'https://academia.sh/en/courses/serverless/durable-state-objects'
course: 'Serverless and Edge Computing'
language: en
updated: '2026-09-13T05:07:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Runs two models on the same workload: read–modify–write on a shared store versus sequential processing in a single-writer object'
    - "Counts lost updates and measures the single instance's queuing behavior at three load levels"
    - 'Computes the weighted access latency of where the object is pinned and works out which of five scenarios fit'
---

# Durable State Objects

A single-writer object model is run for the same key: lost updates drop to zero under concurrent requests. Two constraints that come back in exchange are counted — the single instance queuing per request and the object's geographic pinning — and which of a counter, a room, and a lock scenario fits is written down.

The previous lesson's store left one thing unsolved. The propagation window was tying reads to
the old value; what happens if two edge sites want to change the same key at the same time? In
the fictional **measurement network** this has a concrete counterpart: a branch's validated
reading counter is being incremented by records arriving from two different edge sites at once.

"Read, modify, write" is three separate steps, and something else can slip in between them.
What this lesson measures is how many updates that interleaving drops, and what the model that
brings it to zero asks for in exchange. Both models run on the same workload.

**EC19.** In the shared-store model, a read takes 20 ms, computation 1 ms, and a write 64 ms;
the three are separate steps. **EC20.** Arrivals are deterministic; there is no randomness.
**EC21.** In the single-writer object, the same key is routed to a single instance, requests
queue in arrival order, state is held in the instance's memory, and processing takes 6 ms per
request; there is no concurrent execution.

## Two Models on the Same Key

The first model is familiar: the value sits in a remote store, and every client reads it,
increments it, and writes it back. In the second model, the key is bound to an instance — there
is a single instance in the network for a given key, and every request queues up at it. The step
count is now one instead of three.

```js
// measurement-network/single-writer.mjs — the same key in two models: shared store
// (read-modify-write) and single-writer object (sequential processing). MODEL: arrivals and durations are deterministic.

const CLIENTS = 8, STEPS = 50, TOTAL = CLIENTS * STEPS;
const READ_MS = 20, COMPUTE_MS = 1, WRITE_MS = 64;   // shared store path
const PROCESS_MS = 6;                                 // per-request processing at the single instance

// --- 1. Shared store: each client does read-modify-write in sequence.
const ops = [];
for (let c = 0; c < CLIENTS; c++) {
  let t = c * 3;
  for (let i = 0; i < STEPS; i++) {
    ops.push({ readT: t, writeT: t + READ_MS + COMPUTE_MS + WRITE_MS, readValue: 0 });
    t = t + READ_MS + COMPUTE_MS + WRITE_MS;
  }
}
const events = [];
for (const o of ops) { events.push({ t: o.readT, isRead: true, o }); events.push({ t: o.writeT, isRead: false, o }); }
events.sort((a, b) => a.t - b.t || (a.isRead ? -1 : 1));
let value = 0, overwritten = 0;
for (const e of events) {
  if (e.isRead) { e.o.readValue = value; continue; }
  if (value !== e.o.readValue) overwritten++;
  value = e.o.readValue + 1;
}
console.log(`${CLIENTS} clients x ${STEPS} increments = ${TOTAL} requests, same key (model)`);
console.log("shared store (read-modify-write)");
console.log(`  expected ${TOTAL}   counter's final value ${value}   ` +
  `lost updates ${TOTAL - value}   ${(((TOTAL - value) / TOTAL) * 100).toFixed(1)}%`);
console.log(`  overwritten writes ${overwritten}   operation duration ${READ_MS + COMPUTE_MS + WRITE_MS} ms`);

// --- 2. Single-writer object: the same key goes to a single instance, requests processed in order.
function queueRun(clients, gapMs) {
  const arrivals = [];
  for (let c = 0; c < clients; c++)
    for (let i = 0; i < STEPS; i++) arrivals.push(c * 3 + i * gapMs);
  arrivals.sort((a, b) => a - b);
  let free = 0, processed = 0, waits = [];
  for (const v of arrivals) {
    const start = Math.max(v, free);
    waits.push(start - v);
    free = start + PROCESS_MS;
    processed++;                                     // state in memory, step is atomic
  }
  waits.sort((a, b) => a - b);
  const p95 = waits[Math.ceil(0.95 * waits.length) - 1];
  const avg = waits.reduce((t, v) => t + v, 0) / waits.length;
  return { requests: arrivals.length, processed, rate: (clients * 1000) / gapMs, avg, p95,
           duration: (free - arrivals[0]) / 1000 };
}
const CAP = 1000 / PROCESS_MS;
console.log(`\nsingle-writer object (${PROCESS_MS} ms per request, cap ${CAP.toFixed(1)} req/s)`);
console.log("load".padEnd(22) + "arrival rate".padEnd(14) + "lost".padEnd(9) +
  "avg wait".padEnd(15) + "p95 wait".padEnd(14) + "queue");
for (const [clients, gap] of [[4, 40], [6, 40], [8, 40]]) {
  const r = queueRun(clients, gap);
  console.log(`${clients} clients / ${gap} ms`.padEnd(22) +
    `${r.rate.toFixed(0)}/s`.padEnd(14) + String(r.requests - r.processed).padEnd(9) +
    `${r.avg.toFixed(1)} ms`.padEnd(15) + `${r.p95} ms`.padEnd(14) +
    (r.rate > CAP ? "saturated" : "flowing"));
}
```

```
8 clients x 50 increments = 400 requests, same key (model)
shared store (read-modify-write)
  expected 400   counter's final value 45   lost updates 355   88.8%
  overwritten writes 355   operation duration 85 ms

single-writer object (6 ms per request, cap 166.7 req/s)
load                  arrival rate  lost     avg wait       p95 wait      queue
4 clients / 40 ms     100/s         0        4.5 ms         9 ms          flowing
6 clients / 40 ms     150/s         0        7.5 ms         15 ms         flowing
8 clients / 40 ms     200/s         0        206.5 ms       386 ms        saturated
```

In the shared store, 400 increment requests carry the counter to **45**. Lost updates come to
**355**, or 88.8%. The number is this large because the operation takes 85 ms, and for those 85
ms all eight clients read the same stale value; all eight writes then produce the same result,
and seven of them go to waste. This is not a flaw in the store — the store wrote exactly what it
was told to write. What is lost is that **the interleaving went unseen**.

In the single-writer object, lost updates on the same workload are **0** at all three load
levels. The reason is the model itself: the read, the computation, and the write happen back to
back in a single instance's memory, and that instance never processes another request at the same
time. Zero can be written down as a guarantee because the step has become indivisible.

## Two Constraints That Come Back in Exchange

Zero is not free. The first cost is queuing, and the table reads it at three levels. The cap per
instance is **166.7 req/s**. At a 100/s arrival rate, the average wait is 4.5 ms and p95 is 9 ms.
At 150/s, 7.5 ms and 15 ms. At 200/s, the average wait is **206.5 ms**, p95 is **386 ms**, and the
queue is saturated — because the arrival rate has crossed the cap, the wait keeps growing for the
rest of the run.

The gap between the two load levels stands out here: between 150/s and 200/s, arrivals rise 33%
while the average wait rises from 7.5 ms to 206.5 ms — roughly 28-fold. A single instance is a
unit of scale, and once it crosses its cap it does not stop answering, it **backs up**. The
decision handed over is managing concurrency; the constraint that comes back in exchange is a
single number: 6 ms per request, 166.7 requests per second.

The way to work around the constraint is not replicating the instance — replicating it cancels
single-writer status, and with it, the zero. The way is **splitting the key**: keeping one counter
per branch instead of one counter, which divides the total load by the instance count. The cost
is that a global total no longer sits in one place; whenever the total is requested, every
instance has to be read, and the read count grows with the instance count.

## Where the Object Sits

The second cost is geography. The object sits in one place, and a client far from that place
reaches it **remotely**. All of running at the edge's gain came from proximity, and coordinated
state collapses that proximity down to a single point.

**EC22.** Distances are round trips and are model input. **EC23.** The object is not moved
during the run; a change in placement is not modeled. **EC24.** Scenario rates are total request
rates; the rate per instance is computed from how many instances the key is split across.

```js
// measurement-network/scenario.mjs — the object's geographic pinning and scenario fit
// MODEL: distances are round trips and are model input; the object is not moved during the run.

const PROCESS_MS = 6, CAP = 1000 / PROCESS_MS;
const EDGE = {
  "edge-1": { load: 2600, "busiest edge": 4,  "in region": 62, "per branch": 4 },
  "edge-2": { load: 2100, "busiest edge": 40, "in region": 58, "per branch": 4 },
  "edge-3": { load: 1800, "busiest edge": 55, "in region": 66, "per branch": 4 },
  "edge-4": { load: 1500, "busiest edge": 70, "in region": 60, "per branch": 4 },
  "edge-5": { load: 1200, "busiest edge": 88, "in region": 64, "per branch": 4 },
  "edge-6": { load:  800, "busiest edge": 96, "in region": 70, "per branch": 4 },
};
const PLACEMENT = ["busiest edge", "in region", "per branch"];
const TOTAL = Object.values(EDGE).reduce((t, u) => t + u.load, 0);

console.log(`object access round trip, ${TOTAL} requests spread across the edges (model input)`);
console.log("edge".padEnd(8) + "load".padEnd(9) + PLACEMENT.map((y) => y.padEnd(16)).join(""));
for (const [ad, u] of Object.entries(EDGE))
  console.log(ad.padEnd(8) + String(u.load).padEnd(9) + PLACEMENT.map((y) => `${u[y]} ms`.padEnd(16)).join(""));
console.log("weighted".padEnd(8) + "".padEnd(9) + PLACEMENT.map((y) => {
  const ms = Object.values(EDGE).reduce((t, u) => t + u.load * u[y], 0) / TOTAL;
  return `${ms.toFixed(1)} ms`.padEnd(16);
}).join(""));
console.log("worst edge".padEnd(8) + "".padEnd(9) +
  PLACEMENT.map((y) => `${Math.max(...Object.values(EDGE).map((u) => u[y]))} ms`.padEnd(16)).join(""));
console.log("  coordination-free work finishes at the same edge in 2 ms; the difference is the cost of pinning");

const SCENARIO = {
  "branch counter":        { key: "branch",    instances: 6,   rate: 480, longJobMs: 0 },
  "branch room":           { key: "branch",    instances: 6,   rate: 600, longJobMs: 0 },
  "work order lock":       { key: "work order",instances: 120, rate: 240, longJobMs: 0 },
  "global sequence counter":{ key: "single",   instances: 1,   rate: 900, longJobMs: 0 },
  "nightly reconciliation": { key: "single",   instances: 1,   rate: 5,   longJobMs: 40_000 },
};
console.log(`\nscenario fit (cap per instance ${CAP.toFixed(1)} req/s)`);
console.log("scenario".padEnd(26) + "key".padEnd(12) + "instances".padEnd(11) +
  "total rate".padEnd(13) + "per instance".padEnd(14) + "result");
for (const [ad, s] of Object.entries(SCENARIO)) {
  const perInstance = s.rate / s.instances;
  const reason = s.longJobMs > 0
    ? `does not fit: ${s.longJobMs / 1000} s job locks the instance, ` +
      `${Math.round((s.longJobMs / 1000) * s.rate)} requests queue behind it`
    : perInstance > CAP
      ? `does not fit: ${perInstance.toFixed(0)}/s per instance, ${(perInstance / CAP).toFixed(1)}x the cap`
      : `fits: ${((perInstance / CAP) * 100).toFixed(0)}% of the cap`;
  console.log(ad.padEnd(26) + s.key.padEnd(12) + String(s.instances).padEnd(11) +
    `${s.rate}/s`.padEnd(13) + `${perInstance.toFixed(0)}/s`.padEnd(14) + reason);
}
const fits = Object.values(SCENARIO).filter((s) => s.longJobMs === 0 && s.rate / s.instances <= CAP).length;
console.log(`  ${fits}/${Object.keys(SCENARIO).length} scenarios fit the single-writer object`);
```

```
object access round trip, 10000 requests spread across the edges (model input)
edge    load     busiest edge    in region       per branch      
edge-1  2600     4 ms            62 ms           4 ms            
edge-2  2100     40 ms           58 ms           4 ms            
edge-3  1800     55 ms           66 ms           4 ms            
edge-4  1500     70 ms           60 ms           4 ms            
edge-5  1200     88 ms           64 ms           4 ms            
edge-6  800      96 ms           70 ms           4 ms            
weighted         48.1 ms         62.5 ms         4.0 ms          
worst edge         96 ms           70 ms           4 ms            
  coordination-free work finishes at the same edge in 2 ms; the difference is the cost of pinning

scenario fit (cap per instance 166.7 req/s)
scenario                  key         instances  total rate   per instance  result
branch counter            branch      6          480/s        80/s          fits: 48% of the cap
branch room               branch      6          600/s        100/s         fits: 60% of the cap
work order lock           work order  120        240/s        2/s           fits: 1% of the cap
global sequence counter   single      1          900/s        900/s         does not fit: 900/s per instance, 5.4x the cap
nightly reconciliation    single      1          5/s          5/s           does not fit: 40 s job locks the instance, 200 requests queue behind it
  3/5 scenarios fit the single-writer object
```

When the object is placed next to the busiest edge, the weighted access round trip is **48.1
ms**; placed in the region, it is **62.5 ms**. The first option beats the second on average but
makes the worst edge worse: 96 ms against 70 ms. In other words, placing the object next to the
busiest edge gives 4 ms to the edge carrying 26% of the load while charging 96 ms to the edge
carrying 8%. Placing it in the region flattens this distribution; it treats no one especially well
and no one especially badly.

The third column shows the real decision. When the key is split per branch, each object sits at
its own edge and the weighted access round trip drops to **4.0 ms** — the worst edge is also 4
ms. Compared with coordination-free work finishing at the same edge in 2 ms, the difference is
only 2 ms. Geographic pinning stops being a cost here, because the object already sits where it
is used. Pinning becomes a cost only when the key does not line up with geography.

## Which Use Fits

The last table weighs five scenarios on the same two measures: rate per instance and job
duration. The **branch counter** carries a total load of 480/s, split across six instances, for
80/s per instance — 48% of the cap. The **branch room** runs 600/s, 100/s per instance, and 60% of
the cap. The **work order lock** splits across 120 instances for 2/s per instance, 1% of the cap;
the lock scenario is the most comfortable fit for the single-writer object, because its keys are
naturally numerous and each one is touched rarely.

Two scenarios do not fit, and the reasons differ. The **global sequence counter** asks for 900/s
on a key that cannot be split; that is 5.4 times the cap, and a single instance cannot carry it.
The mismatch here is about rate. **Nightly reconciliation** asks for only 5/s, but the job itself
takes 40 seconds and locks the instance for that whole span; **200 requests** queue up behind it.
The mismatch here is about duration. The same model rejects at two separate thresholds: the 6
ms-per-request cap, and the request's own indivisible length.

**3 of 5** scenarios fit. The rule can be written in one sentence: the single-writer object is
for coordination jobs with many keys, each touched rarely, whose operations are short. When any
one of the three conditions breaks, the model rejects the job and writes the rejection down as a
number.

## Summary

- In the shared store, 400 concurrent increments carry the counter to 45: lost updates come to
  355, or 88.8%. What is lost is not the store writing incorrectly, but the interleaving going
  unseen.
- In the single-writer object, lost updates on the same workload are 0 at all three load levels;
  the read, the computation, and the write happen indivisibly in a single instance's memory.
- The first constraint that comes back in exchange is the cap: 6 ms per request, 166.7 req/s per
  instance. At arrival rates of 100, 150, and 200/s, the average wait is 4.5, 7.5, and 206.5 ms; a
  33% rise in arrivals grows the wait roughly 28-fold.
- The second constraint is geographic pinning: the weighted access round trip is 48.1 ms at the
  busiest edge, 62.5 ms in the region, and 4.0 ms when split per branch. The cost arises only when
  the key does not line up with geography.
- 3 of 5 scenarios fit: the branch counter (48% of the cap), the branch room (60%), the work order
  lock (1%). The global sequence counter fails on rate (5.4 times the cap), nightly reconciliation
  fails on duration (a 40 s job with 200 requests queued behind it).

## Next Step

What the two scenarios this lesson rejected have in common is that the request **has to wait for
a response**. Nightly reconciliation took 40 seconds and queued two hundred requests behind it,
yet no one was waiting on that job's result. Most of the records arriving from the edge in the
fictional network are like this: a meter reading is taken, its receipt is acknowledged, and
processing it can be put off. If we could defer a job at the edge instead of doing it immediately,
would the single instance's cap stop being a limit? Where does deferred work sit, how many times
is it retried, and how does the edge runtime's duration limit cut that wait short? The next
lesson measures the ways to defer work at the edge: where the deferred work piles up, what the
delivery guarantee is, and where a scheduled trigger at the edge diverges from one in the region.
