Mastering the Durable Objects overloaded Error: Avoiding the 1,000-Requests-Per-Second Soft Limit and the Single-Thread Bottleneck

Durable Objects (DO) make an appealing promise: “a single, globally unique instance guarantees strong consistency.” That’s a perfect fit for domains where ordering matters — chat rooms, game sessions, payment state machines. The problem shows up when you design around that promise while forgetting the reason it holds — a single DO instance physically runs on a single thread. As traffic grows, you suddenly get a flood of 502s with overloaded errors, one slow external API call ends up delaying every other user sharing the same room, and alarm() retry logic turns into an incident where a payment gets charged twice.
This post walks through all three traps with reproducible code, backed by numbers straight from the official docs.
Key takeaways
- A single DO instance has a soft limit of 1,000 requests per second, and if requests to the same object pile up within a 10-second window, you get a
Too many requests for the same object within a 10 second windowerror.await fetch()opens the input gate and allows interleaving with other requests, butawait storage.*calls andblockConcurrencyWhile()are fully serialized, creating genuine head-of-line blocking.- alarm() only auto-retries up to 6 times with exponential backoff starting at 2 seconds. If a side effect executed during a retry (a payment, a webhook) isn’t idempotent, it runs twice.
- SQLite-backed DOs have a storage limit of 1GB on Free / 10GB on Paid per object, and writes fail with
SQLITE_FULLonce you exceed it. As of July 20, 2026, the dashboard added a namespace-level Total storage chart.
Why DO Is Single-Threaded, and the Latency Sequential Processing Creates
The core contract of a Durable Object is: “an object created with a given ID runs in exactly one physical location worldwide, and every request headed there is processed by that instance in order.” Cloudflare’s own docs illustrate this with a booking-system example.
“all booking requests for a venue must be serialized to prevent double-booking”
That serialization is exactly why you can prevent double bookings without locks or distributed transactions. But it isn’t free. Every synchronous execution bound for the same object has to queue up on the timeline of a single JS thread. Take a deceptively simple counter DO.
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
async fetch(req) {
let count = (await this.ctx.storage.get("count")) ?? 0;
count += 1;
await this.ctx.storage.put("count", count);
return Response.json({ count });
}
}
This code looks harmless on its own, but the story changes once hundreds of requests per second start hitting the same id. Cloudflare’s own production guidance pins down the throughput of a single object:
A single Durable Object can handle roughly 500 to 1,000 requests per second, depending on compute complexity.
In other words, “DOs scale infinitely” is only half true. You can scale the number of objects infinitely, but the throughput of any single object can’t exceed the limit of a single CPU core. Giving each chat room its own DO works beautifully within that limit — but the moment you funnel all traffic through one object, like “a single global counter” or “a single global rate limiter,” that object becomes a bottleneck.
The ‘Overloaded’ Error: Exact Trigger Conditions and How to Reproduce It
The Official Soft Limit and Four Error Messages
Cloudflare’s official limits documentation states it plainly:
“An individual Object has a soft limit of 1,000 requests per second.” “A Durable Object that receives too many requests will, after attempting to queue them, return an overloaded error to the caller.”
The phrase “soft limit” matters here. It isn’t a hard limit that blocks you the instant you cross 1,000 req/s — instead, once you cross it, the system attempts to queue requests, and only throws an error once the queue itself can’t cope. The troubleshooting docs actually break the overloaded error down into four distinct messages, each with a different cause:
Durable Object is overloaded. Too many requests queued.— the number of requests sitting in the queue is simply too largeDurable Object is overloaded. Too much data queued.— the total data volume of the queued requests is too largeDurable Object is overloaded. Requests queued for too long.— the longest-waiting request in the queue has exceeded its wait-time budgetDurable Object is overloaded. Too many requests for the same object within a 10 second window.— the most severe overload signal, triggered only when an extreme number of requests pile up on the same object within a 10-second window
That last message is the “10-second window” condition referenced in this post’s title. The docs note that this message “does not replace the other overloaded messages, and is only returned in more extreme overload situations.” In other words, if you’re seeing this specific message, traffic has already piled up quite severely.
Another easy-to-miss cause isn’t request volume at all — it’s I/O latency. The error-handling docs put it this way:
Infrastructure exceptions can stem not only from “too many requests hitting a single Durable Object” but also from “requests piling up in the queue due to slow or excessive I/O.”
Meaning: even if your request rate is far below 1,000/s, if each request takes a long time waiting on a slow external API, the queue can back up and produce an overloaded error anyway.
Reproducing It
Actually hammering production is risky, so here’s code to reproduce it in a staging environment. The key is driving a burst of requests at the same DO id in a short window.
// worker.js — load-generation endpoint (staging only)
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname === "/hammer") {
const id = env.COUNTER.idFromName("shared-hot-object");
const stub = env.COUNTER.get(id);
const batch = Array.from({ length: 500 }, () =>
stub.fetch("https://do/increment").catch((e) => e)
);
const results = await Promise.allSettled(batch);
const overloaded = results.filter(
(r) => r.status === "fulfilled" && r.value?.status === 500
);
return Response.json({ sent: batch.length, overloaded: overloaded.length });
}
return new Response("ok");
},
};
There’s a catch worth knowing about. Workers’ subrequest limits are 50 per request on the Free plan and 10,000 per request on Paid, and regardless of plan, only 6 outbound connections can be open concurrently while waiting on response headers. So even though firing off thousands of requests via Promise.all looks like it happens all at once, it’s actually processed 6-at-a-time in sequence — and on Free you can’t even send more than 50 per request to begin with. If you actually want to generate 1,000 req/s, it’s far more realistic to hit the /hammer endpoint concurrently from many Worker invocations using an external load tool like autocannon or hey.
# hit /hammer with 100 concurrent connections for 10 seconds, from outside
npx autocannon -c 100 -d 10 https://your-worker.workers.dev/hammer
That way, each Worker invocation fires 500 fetches at the same DO, and once the concurrent invocations overlap, you can actually observe the Too many requests for the same object within a 10 second window error in practice. The error object carries an .overloaded property, so your client should distinguish it like this:
try {
const resp = await stub.fetch(req);
return resp;
} catch (e: any) {
if (e.overloaded) {
// retrying only makes the overload worse — fail fast instead
return new Response("busy", { status: 503 });
}
if (e.retryable) {
// safe to retry with exponential backoff if the request is idempotent
}
throw e;
}
The official docs are explicit about this too: retrying an error where .overloaded is true “will exacerbate the overload and increase the overall error rate.”
Head-of-Line Blocking Caused by Slow External API Calls
What the Input Gate and Output Gate Actually Do
This is where most of the confusion lives. It’s tempting to assume that “since a DO is single-threaded, calling an external API with await fetch() freezes every other request headed to that object” — but that’s not accurate. Cloudflare breaks this down more precisely with an input gate / output gate mechanism.
- The input gate only blocks new events (incoming requests, fetch responses) while synchronous JavaScript is actively executing.
- While waiting on
await fetch()or a KV storage method, the input gate stays open, so other requests are free to interleave and run. - By contrast, while waiting on a storage operation (
storage.get/put/transaction, etc.), the input gate stays closed, so no other request can interleave. - The output gate holds back outgoing responses and fetch requests until any pending storage writes complete — the “message sent” response doesn’t go out until the data is safely persisted.
In other words, simply awaiting one external API call doesn’t freeze the whole object. Head-of-line blocking severe enough to drag down other requests actually comes from three specific patterns.
Three Genuinely Dangerous Patterns
1) Putting a slow external call inside blockConcurrencyWhile(). This method queues up every event except the ones the callback itself generates, until the callback finishes. A common mistake is “warming the cache” in the constructor by calling an external API.
export class RoomState extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
// anti-pattern: awaiting a slow external API inside the init block
this.config = await fetch("https://config-service.example.com/room").then((r) => r.json());
});
}
}
If that config service slows down or briefly goes down, every single request headed to this object waits until the callback finishes. The official docs state that blockConcurrencyWhile has a 30-second timeout, after which “the Durable Object is reset” — and if the callback throws, the object gets terminated and reset just the same. The recommendation is clear: “the callback should do as little as possible, since this improves overall request throughput.” Because SQLite-backed storage operations are atomic, blockConcurrencyWhile is rarely needed for ordinary request handling — it’s safest to reserve it for things like schema migrations in the constructor.
2) Storage operations piling up. Unlike await fetch(), a call like await this.ctx.storage.get(...) does not open the input gate. So when storage I/O is the bottleneck, requests genuinely queue up. Batching multiple keys together instead of get()-ing them one at a time is the practical fix here.
// slow: N individual round trips, input gate stays closed the whole time
for (const key of keys) {
await this.ctx.storage.get(key);
}
// fast: a single round trip
const values = await this.ctx.storage.get(keys); // keys: Array<string>
3) Synchronous CPU work. While parsing a big JSON blob or running a heavy loop, there’s no await point to begin with, so the event loop itself is blocked regardless of the input gate. Logic that handles large payloads inside a DO should be moved out to the Worker, or split into chunks.
The Fix: Sharding to Spread Requests Across Multiple DO Instances
The conclusions from the previous two sections converge on one point — you can’t grow the capacity of a single object, so you have to grow the number of objects. The official guide flags “one global Durable Object handling every chat room” as an explicit anti-pattern, and gives a formula for the number of objects you need:
Number of DOs needed = (total requests per second) / (requests a single DO can handle)
For example, if a game-session service receives 500,000 requests per second and a single object handles 500–1,000 requests per second, you need 500–1,000 per-session DOs — not a single global coordinator. Implementing a global rate limiter as one DO is dangerous for the same reason — it becomes a single chokepoint that all traffic funnels through, and it simply doesn’t scale.
In practice, you shard on a natural partition key — user ID, room ID, tenant ID.
// anti-pattern: global singleton
const id = env.RATE_LIMITER.idFromName("global");
// better: shard per user — every user gets an independent object
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);
// finer-grained: hash into N buckets (for approximating a global counter)
function shardId(key, shardCount = 64) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return `shard:${hash % shardCount}`;
}
const id = env.COUNTER.idFromName(shardId(userId));
There’s a trade-off, of course. Once you shard, logic that needs an “exact global total” (total concurrent users, say) can no longer be read instantly from a single object. The usual compromise here is a fan-in pattern: each shard periodically reports its own count to an aggregator DO via alarm — trading real-time accuracy for an approximation that’s a few seconds stale.
alarm()’s Exponential Backoff Retries and the Duplicate-Execution Bugs Non-Idempotent Code Creates
Why “Up to 6 Retries” Is a Trap
alarm() is the API that gives you reliable scheduled execution inside a DO. The official docs spell out exactly what that reliability guarantees:
“The alarm() handler has guaranteed at-least-once execution and will be retried upon failure using exponential backoff, starting at 2 second delays for up to 6 retries.”
So it auto-retries at most 6 times, at intervals of 2s → 4s → 8s → 16s → 32s → 64s. There’s one more sentence here that really matters:
“If an unexpected error terminates the Durable Object, the alarm() handler may be re-instantiated on another machine. Following a short delay, the alarm() handler will run from the beginning on the other machine.”
That sentence is the crux of the duplicate-execution bug. alarm() doesn’t only get retried when it throws an exception — if the DO process itself dies (infrastructure failure, resource exhaustion, an unexpected crash, whatever), it runs from the beginning again. That means a scenario like “the payment call inside the alarm() handler succeeds, but the process dies right before the state gets saved afterward” is genuinely possible — and when that happens, the re-run alarm() executes the already-successful payment call a second time.
// dangerous pattern — not idempotent
async alarm() {
const order = await this.ctx.storage.get("pendingOrder");
if (!order) return;
// ① what if the process dies right after this fetch succeeds,
// but before reaching ②?
await fetch("https://payments.example.com/charge", {
method: "POST",
body: JSON.stringify(order),
});
// ② if execution never reaches here, ① fires again on the next run
await this.ctx.storage.delete("pendingOrder");
}
The docs note that calling deleteAlarm() inside the alarm() handler “can prevent retries on a best-effort basis, but this is not guaranteed.” In other words, you can’t rely on deleteAlarm() as an idempotency mechanism.
A Pattern That Guarantees Idempotency
The most practical approach is combining two things: making the external call itself idempotent, and committing state before making the call.
async alarm(alarmInfo) {
const order = await this.ctx.storage.get("pendingOrder");
if (!order || order.status === "charged") return;
if (alarmInfo?.isRetry) {
console.log(`retry #${alarmInfo.retryCount}, resuming order ${order.id}`);
}
// 1. commit an "in-progress" idempotency key to storage before the call
// (order.idempotencyKey is issued once, at initial creation)
await fetch("https://payments.example.com/charge", {
method: "POST",
headers: { "Idempotency-Key": order.idempotencyKey },
body: JSON.stringify(order),
});
// 2. only transition to "completed" after success
order.status = "charged";
await this.ctx.storage.put("pendingOrder", order);
await this.ctx.storage.delete("pendingOrder"); // cleanup
}
Three things matter here:
- Pass an idempotency key to the external API. Most payment APIs (Stripe included) support an
Idempotency-Keyheader, so calling with the same key twice results in only one actual charge. - Log whether it’s a retry via
alarmInfo.isRetry/retryCount, so you can trace back a suspected duplicate execution later. - If you catch the exception yourself and reschedule with your own
setAlarm()call (i.e., unbounded retries), remember that this only raises the stakes on getting idempotency right. Wanting “more than 6 retries” is itself a signal to ask again whether that side effect is truly idempotent.
Storage Limits and Monitoring for SQLite-Backed DOs
Free/Paid Limits
Durable Objects now default to SQLite-backed storage. Per the official limits docs, the caps are:
- Storage per object: 1GB on Free, 10GB on the Workers Paid plan
- Total account-wide storage: 5GB combined across the whole account on Free; effectively unlimited on Paid (only the 10GB per-object cap applies)
- Key/value size: key plus value combined cannot exceed 2MB
- SQL constraints: up to 100 columns per table, a 2MB max size for strings/BLOBs/rows, and a 100KB max length for SQL statements
What happens once you exceed the limit? The official docs describe it exactly:
Once an object reaches its maximum storage limit (10GB on Paid, 1GB on Free), write operations —
INSERT,UPDATE,put(),sql.exec()— fail with adatabase or disk is full: SQLITE_FULLerror. Read operations likeSELECT,get(),list(), along withDELETE, continue to work, so you can free up space.
So exceeding the limit doesn’t take down the whole service — writes get blocked, but you can recover by deleting data. It’s safest to write your handling code like this:
try {
this.ctx.storage.sql.exec(
"INSERT INTO my_table (key, value) VALUES (?, ?)",
key,
value,
);
} catch (e) {
if (e.message.includes("SQLITE_FULL")) {
// storage limit reached — reads/deletes are still available
// clean up stale data, or return a meaningful error to the caller
}
throw e;
}
Checking Capacity Before You Hit the Limit
It’s far better to catch this with an alarm before you hit the limit than to handle it after the fact. The SQLite storage API exposes a property that reads the current DB size, in bytes, directly.
async fetch(req) {
const sizeBytes = this.ctx.storage.sql.databaseSize;
const limitBytes = 10 * 1024 * 1024 * 1024; // 10GB on the Paid plan
if (sizeBytes > limitBytes * 0.8) {
console.warn(`storage at ${(sizeBytes / limitBytes * 100).toFixed(1)}% of limit`);
// send an alert, schedule an alarm to clean up old rows, etc.
}
// ...
}
If you want a view across your whole account, Cloudflare added a namespace-level “Total storage” chart to the Durable Objects dashboard on July 20, 2026. It shows the maximum reported storage per hour, and it’s useful for spotting growth trends, confirming whether a cleanup actually worked, or catching an unexpected usage spike. That said, the chart only applies to SQLite-backed namespaces and doesn’t yet support tracking at the individual object level (by ID/name) — so checking whether a specific object is near its limit still means reading databaseSize from inside the object itself, as shown above.
Wrapping Up: A Production Checklist
To sum up, before putting a DO into production, it’s worth checking at least the following:
- Confirm that keys likely to see traffic spikes (a global counter, a global lock, a global rate limiter) aren’t all funneled into a single DO id.
- Confirm that
blockConcurrencyWhile()callbacks don’t contain external API calls or slow operations — remember the 30-second timeout and the reset-on-exception behavior. - Handle
.overloadedand.retryableas separate cases on the client/Worker side, and never retry on overloaded. - Re-check whether the side effects inside alarm() (payments, webhooks, inventory decrements) are actually idempotent, and use the external API’s idempotency key wherever possible.
- If you’re on a SQLite-backed DO, periodically check
databaseSize, or watch the dashboard’s Total storage chart for account-wide trends.
Durable Objects don’t give you “strong consistency” and “infinite scalability” at the same time. Consistency comes from the single-thread serialization of one object; scalability comes from how well you split that object up. Recognize this trade-off at design time, and most of the traps covered above are avoidable from the start.