Why Cloudflare Workers throw "Script will never generate a response"
The hanging-promise patterns that trigger it, and the structured-cloning gotcha most people miss.
You've just deployed a critical update to your Cloudflare Worker. Locally everything passed. In staging it looked fine. But in production your error logs are suddenly flooded with a single, unhelpful sentence: Script will never generate a response. No stack trace. No line number. Just a 500-level failure and a blank screen for your users. This isn't an ordinary exception — it's the V8 isolate runtime deciding your code is a dead end and terminating it. This post is about why that happens, including the structured-cloning gotcha almost nobody catches until it's in production.
Anatomy of a Runtime Deadlock
The first thing to understand is that Script will never generate a response is a meta-error. It isn't thrown by your code. It's emitted by Workerd, the Cloudflare edge runtime, when the execution finishes every reachable code path without ever producing a Response object. The runtime waited, the promises settled (or didn't), nothing returned a response, and so it gave up and told you so.
The promise lifecycle in V8 isolates
A Worker's fetch handler is a promise. The runtime invokes it, then waits for that promise to resolve to a Response. If the promise resolves to undefined, or if a branch of your logic never resolves at all, the runtime has nothing to send. It can't wait forever — that would tie up an isolate — so once it determines no response is coming, it kills the script. The error is the runtime's way of saying "your code is a function that, on this path, returns nothing."
Why this differs from a standard JS exception
A normal exception like Error: 1101 means your code threw — there's a stack, a message, a place to look. "Script will never generate a response" means your code didn't throw and didn't return. That distinction matters enormously for debugging, because your try/catch blocks are useless here. There's no exception to catch. The control flow simply fell off the end of the world.
The Hanging Promise Pattern
By far the most common cause is an orphaned promise — code that does the work but forgets to actually return the result back up the chain.
The difference between return fetch() and await fetch()
Look closely at these two handlers. One works and one produces the dreaded error:
// BROKEN: the fetch happens, but its result is never returned.
export default {
async fetch(request) {
fetch("https://api.example/data"); // floating promise
// function ends, resolves to undefined -> no Response
},
};
// CORRECT: the result of the async work flows back out.
export default {
async fetch(request) {
return await fetch("https://api.example/data");
},
};In the broken version, the fetch fires and a promise is created, but because it's neither returned nor awaited, the handler resolves to undefined. The runtime gets nothing and terminates the script. This bug is insidious because the network call succeeds — you'll see the subrequest in logs — it just goes nowhere.
Pitfalls with Promise.race and Promise.all
Composed promises introduce subtler versions. With Promise.race([a, b]), if neither a nor b ever settles, the race never settles, and your handler hangs. With Promise.all([a, b, c]), a single branch that never resolves — say a fetch to a host that silently drops the connection with no timeout — stalls the entire collection. The runtime waits, sees nothing resolving, and kills the script. Always pair external work with a timeout via AbortController so a hung dependency can't take your whole Worker down.
The Structured Cloning Gotcha: The Silent Killer
This is the cause that most people never figure out, because it doesn't look like a promise bug at all. Several Workers APIs — postMessage, the writable side of a TransformStream, KV metadata, queue messages — don't accept arbitrary JavaScript objects. They serialize whatever you pass using the Structured Clone Algorithm, and if your object isn't cloneable, the operation throws deep inside the runtime in a way that can short-circuit your response path.
What is the Structured Clone Algorithm?
Structured cloning is the browser's built-in deep-copy mechanism. It handles plain objects, arrays, Maps, Sets, Dates, ArrayBuffers, and typed arrays. What it explicitly cannot clone are functions, Symbols, DOM nodes, and class instances that carry methods or non-enumerable internal state. Try to clone one of those and you get a DataCloneError.
Why JSON.stringify isn't enough to test for this
Here's the trap that burns people: developers test their object with JSON.stringify, it works, and they assume it's safe to pass into a stream. But the two algorithms have different rules. JSON.stringify silently drops functions and Symbols — it doesn't throw. Structured clone throws on them. So an object that stringifies cleanly can still blow up the moment it enters a TransformStream:
class Session {
constructor(id) { this.id = id; }
refresh() { /* a method! */ }
}
const s = new Session("abc");
JSON.stringify(s); // '{"id":"abc"}' — looks totally fine
// But structured clone can't copy a class instance with methods:
const { writable } = new TransformStream();
const writer = writable.getWriter();
await writer.write(s); // DataCloneError -> response never generatedIdentifying non-serializable objects in your middleware
This bites hardest in middleware chains where an object accumulates non-cloneable cruft as it passes through layers — a logger instance, a bound function, a class wrapper around config. Everything works until the final layer tries to stream or enqueue it. The fix is to pass plain data across these boundaries: strip objects down to their cloneable fields before handing them to a stream, queue, or KV metadata. A small "safe clone" helper that round-trips through structuredClone() in a try/catch will surface the problem at the boundary instead of letting it kill the response.
Misusing event.waitUntil
waitUntil is meant to extend the isolate's life so background work — logging, cache writes, analytics — can finish after the response is sent. It's often treated as fire-and-forget, but a hanging promise inside waitUntil can interfere with the whole lifecycle.
How waitUntil extends the worker's life
When you call ctx.waitUntil(promise), you're telling the runtime to keep the isolate alive until that promise settles. That's exactly what you want for short background tasks. But the runtime still enforces wall-clock and CPU limits, and a waitUntil promise that never resolves will eventually trigger a termination.
The double-return bug
export default {
async fetch(request, env, ctx) {
// A background task with no timeout and an infinite retry loop.
ctx.waitUntil(
(async () => {
while (true) {
await logToSlowEndpoint(request); // never breaks out
}
})()
);
return new Response("OK"); // response IS returned...
},
};You returned a response, so this should be fine — except the unbounded waitUntil loop keeps the isolate pinned until the runtime kills it for exceeding limits, and depending on timing the termination can surface as the "never generate a response" error rather than a clean completion. Always bound background work with a timeout and an exit condition.
CPU and Memory Limit Terminations
Sometimes the script "will never generate a response" because the runtime executed it right up to a resource limit and killed it before it reached the return.
The 10ms/50ms CPU wall
The standard plan caps CPU time per request. Note this is CPU time, not wall time — waiting on a fetch doesn't count, but parsing a large JSON body, running a regex over a big string, or serializing a deep object does. A request that does too much synchronous work hits the wall mid-execution. The work never finishes, so no response is produced, and you get the error.
How memory leaks trigger isolate termination
Workers run with a 128MB memory ceiling. Accumulate too much in memory — a runaway buffer, an unbounded array, a stream you never drain — and the isolate is terminated for Out of Memory before it can respond. Here is roughly how the limits map to the symptom:
- CPU exceeded (50ms standard): heavy synchronous compute, big regex, large serialization. Manifests as termination mid-execution.
- Memory exceeded (128MB): unbounded buffering, large request/response bodies held in memory. Manifests as OOM termination.
- Subrequest limit (50/1000): too many
fetchcalls in one invocation. Manifests as a thrown error, but in a loop can starve the response path.
Diagnostic Strategies: How to Catch the Uncatchable
Because this error bypasses try/catch, you can't just wrap your handler and log it. You have to reconstruct what happened from the outside.
Using wrangler tail to find the last successful log line
Run wrangler tail and look for the last log line your code emitted before the failure. The gap between that line and where you expected the response tells you which branch hung or which API call threw a clone error. The last breadcrumb is your map.
A safe-clone utility to pre-validate objects
For the structured-cloning class of bug, validate objects before they cross a serialization boundary:
function assertCloneable(obj, label) {
try {
structuredClone(obj);
} catch (e) {
throw new Error(`Non-cloneable object at ${label}: ${e.message}`);
}
}
// Right before streaming or enqueueing:
assertCloneable(payload, "queue.send");This converts a mysterious "never generate a response" into a loud, located error with the exact boundary named — far easier to fix than a silent runtime termination.
Monitoring with GlitchReplay
The fundamental problem with this error is the lack of context. The runtime tells you the script failed but not where, and your try/catch never fired. A Sentry-compatible SDK integrated into your Worker captures the breadcrumbs leading up to the termination — the last fetch, the last log, the state of the request — so even when the isolate dies without a clean exception, you have a trail.
Capturing breadcrumbs before the isolate dies
By recording breadcrumbs as your handler runs and flushing them via ctx.waitUntil, you preserve the sequence of events even when the final step is a hard termination. You get to see "fetched user, opened stream writer, wrote payload — then nothing," which points straight at the clone error.
Seeing what the user did right before the 500
Pairing the Worker context with a session replay closes the loop on the front-end side: you can watch exactly what the user did to trigger the request that hung. For more on the patterns behind this error, see our guides on debugging Cloudflare Workers in production and error tracking on Cloudflare Workers, and if you're on Next.js, the OpenNext deployment errors that share the same root causes.
The economics matter here too. These termination errors tend to cluster — a bad deploy can produce thousands in minutes — and on per-event pricing you're penalized for the very incident you're trying to debug. GlitchReplay's flat-rate pricing means you capture every one of them with full context and never think about volume.
"Script will never generate a response" feels like a wall because the runtime gives you nothing to grab onto. But it always comes back to the same handful of causes: a promise that never returned, an object that couldn't be cloned, a background task that wouldn't stop, or a resource limit hit mid-execution. Instrument the boundaries, bound your background work, and capture breadcrumbs so the next time it happens you're reading a trail instead of staring at a blank screen. If you're tired of cryptic edge failures with no context, give GlitchReplay a try.
GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.