DEV Community

강해수
강해수

Posted on • Originally published at dailymanuallab.com

My Durable Object processed 4 req/s instead of 40 — the culprit wasn't storage

A 200ms outbound webhook call was serializing every single request through my Durable Object, and I spent the first hour blaming the wrong thing.

Durable Objects enforce a strict execution model: one fetch handler runs at a time. If a second request arrives while the first is still awaiting anything — storage, a network call, a sleep — it queues behind it. That's the consistency guarantee, and it's intentional. What I missed is that the queue doesn't care what you're awaiting. A storage.put() that takes 5ms and an outbound fetch() that takes 300ms both hold the same lock. At 10 concurrent callers, you're not running 10 operations in parallel — you're running them in a single-file line, each one waiting for the full execution time of the one ahead of it.

My DO was flushing a write buffer: save to storage, then POST to a webhook. Under load during a campaign spike (12K writes/minute), wrangler tail started showing queue depth errors. I assumed KV back-pressure — I've hit the ~1,000 writes/second namespace cap before — so I rewrote the buffer to batch puts. Throughput improved maybe 15%. Still serialized. A quick timer log inside the handler told the real story:

const t1 = Date.now();
await this.state.storage.put("lastSeen", t1);
console.log(`storage.put: ${Date.now() - t1}ms`); // 3–8ms

const t2 = Date.now();
await fetch("https://hooks.example.com/webhook", { method: "POST", body: JSON.stringify({ ts: t1 }) });
console.log(`outbound fetch: ${Date.now() - t2}ms`); // 180–420ms
Enter fullscreen mode Exit fullscreen mode

The fix was architectural, not a micro-optimization. The DO should own state, not side effects. I moved the webhook call to a Queue binding — env.WEBHOOK_QUEUE.send(body) runs in under 5ms and doesn't block on consumer acknowledgment. The DO drops the payload and moves on immediately. Lock held for single-digit milliseconds instead of 400.

The second part of the fix — parallelizing read-only storage calls with Promise.all() instead of sequential await chains — shaved another chunk off p95 latency and is worth knowing about even if you never touch a webhook.

I wrote up the full breakdown — including the Promise.all() read pattern, what the input gate actually controls in the DO event loop, and how to test serialization behavior locally with wrangler dev — over on dailymanuallab.com.

Full post →

Top comments (0)