A production Cloudflare Worker was returning HTTP 500 from an SSR route.
The database was not the immediate problem. The page rendering itself was not the immediate problem either.
The stack trace pointed into a background cache helper:
TypeError: Cannot read properties of undefined (reading 'waitUntil')
The helper was roughly doing this:
executionContext.waitUntil(cache.put(key, response));
The problem was simple once we found it:
executionContext was undefined at that layer.
The more important architecture problem was that an optional cache write was allowed to crash the entire SSR request.
That should not happen.
Where ctx actually comes from
In Cloudflare Module Workers, the normal fetch handler receives three arguments:
export default {
async fetch(request, env, ctx) {
// ...
}
}
Cloudflare documents ctx.waitUntil(promise) as a way to extend the invocation so non-blocking work can continue after the response is returned.
The runtime supplies ctx at the Worker boundary.
But your application may have several layers between that boundary and the code that eventually wants to schedule background work:
Worker fetch(request, env, ctx)
-> framework adapter
-> SSR handler
-> route loader
-> cache helper
-> scheduleEdgeCachePut(...)
Cloudflare knows the execution context.
Your deepest helper does not know it unless something passes or imports the capability correctly.
How the context gets lost
There are several common ways.
Framework adapter signature mismatch
A framework’s generated Worker handler may accept a different object shape than your custom wrapper expects.
You think you passed:
handler(request, env, ctx)
but a nested integration actually receives only:
handler(request, env)
The app still renders until some helper touches ctx.
Refactoring a helper
A utility once lived directly inside fetch() and had access to ctx.
Later it moves into a shared module, but the function signature is not updated consistently.
Optional argument treated as required
A helper accepts:
function scheduleCachePut(ctx?: ExecutionContext) { ... }
but then immediately calls:
ctx.waitUntil(...)
The type and runtime behaviour disagree.
Tests call the helper directly
Unit/integration tests may exercise the function without creating a real Worker execution context.
The helper works in one production path but explodes in the test or another adapter path—or vice versa.
Alternate entry points
Scheduled jobs, RPC, service bindings or custom framework entry points may not flow through exactly the same wrapper code as HTTP requests.
A shared helper that assumes one context shape can fail elsewhere.
Why this should not have produced a page 500
The failing work in our case was an edge-cache write.
The user did not need the cache write in order to receive the current SSR response.
The intended lifecycle was:
- render the page;
- return the response;
- update cache in the background.
Instead, step 3 threw synchronously before the response could complete.
That turned an optimization into an availability dependency.
A useful rule is:
optional background acceleration should not become a correctness requirement by accident.
If cache population fails, the next request may be slower.
It should not automatically mean the current request becomes HTTP 500.
Fix option 1: pass the execution context explicitly
The most explicit architecture is to make the dependency real in your function signatures.
async function renderPage(
request: Request,
env: Env,
ctx: ExecutionContext,
) {
// ...
scheduleEdgeCachePut(ctx, cacheKey, response.clone());
}
Then:
function scheduleEdgeCachePut(
ctx: ExecutionContext,
key: Request,
response: Response,
) {
ctx.waitUntil(caches.default.put(key, response));
}
This is good when the helper is truly Worker-specific and every caller should have context.
The type system can now tell you when a caller forgot the dependency.
Fix option 2: make optional cache scheduling genuinely optional
In our production fix, the missing execution context was not allowed to turn an optional cache update into a 500.
For a helper whose cache write is genuinely best-effort, the API can model that:
function scheduleEdgeCachePut(
ctx: ExecutionContext | undefined,
key: Request,
response: Response,
) {
if (!ctx) {
return;
}
ctx.waitUntil(caches.default.put(key, response));
}
The exact fallback can vary.
You might:
- skip the cache write;
- log a structured warning;
- await the cache write in a controlled environment;
- use a different scheduling primitive.
The key is to decide deliberately.
Do not let JavaScript decide by throwing Cannot read properties of undefined.
Fix option 3: import waitUntil directly
Since August 2025, Cloudflare Workers supports importing waitUntil directly:
import { waitUntil } from "cloudflare:workers";
Then a deep helper can do:
waitUntil(caches.default.put(key, response));
without threading ctx through every intermediate function.
Cloudflare says the imported function works like ctx.waitUntil() and is particularly useful for deeply nested utilities/modules.
This can remove plumbing in a Worker-specific codebase.
I would still keep the architectural boundary clear: a generic package should not silently depend on the Cloudflare runtime unless that is intentional.
Do not destructure waitUntil from ctx
There is a second runtime trap worth mentioning.
Cloudflare’s current best-practices documentation warns against doing:
const { waitUntil } = ctx;
waitUntil(somePromise);
because losing the method’s binding can produce an Illegal invocation error.
Use:
ctx.waitUntil(somePromise);
or the supported direct import.
That error is different from ctx being undefined, but both can surface when teams refactor background-work helpers.
waitUntil is not a durability primitive
This matters beyond caching.
Cloudflare describes waitUntil as appropriate for work after the response such as:
- cache writes;
- analytics;
- logging;
- cleanup;
- certain notifications.
Cloudflare’s best practices also say to await work before returning when the response depends on that work.
So do not put critical customer state into waitUntil merely because you want a faster response.
Examples I would not casually make best-effort background work:
- marking a verified payment as applied;
- reserving the only remaining booking slot;
- persisting a required entitlement;
- recording an accounting transaction that must exist before confirmation.
If the business promise depends on it, make the durable operation part of the authoritative request or use an appropriate durable asynchronous architecture.
Cache writes are a perfect waitUntil use case—if they are isolated
A cache is usually a derived performance layer.
If the source response is already valid, the application can often return it and populate the cache afterward.
That makes cache writes a natural waitUntil candidate.
But there are two failure boundaries to handle:
Scheduling failure
Can the code safely call waitUntil here?
Promise failure
What happens if cache.put() itself rejects?
For observability, I prefer to attach controlled logging to the promise rather than let a background rejection disappear silently.
For example:
const task = caches.default.put(key, response).catch((error) => {
console.error("edge_cache_put_failed", {
message: error instanceof Error ? error.message : String(error),
});
});
ctx.waitUntil(task);
Do not include sensitive headers/body content in logs.
The exact debugging sequence I use
If production says:
Cannot read properties of undefined (reading 'waitUntil')
I check:
- Find the first application frame in the stack. Which helper touched
.waitUntil? - Inspect that function signature. Is the context marked optional, required or untyped?
- Trace backward to the Worker entry point. Where did
ctxstop being forwarded? - Inspect framework adapters. Does the deployed handler receive/pass the third fetch argument?
- Check alternate callers/tests. Who can call the helper without a Worker context?
- Classify the background task. Is it optional performance work or required business state?
- Choose explicit pass-through, safe optional handling or direct import. Do not paper over required state with optional chaining.
- Add a regression test. Rendering without a cache scheduling context should behave according to the contract.
- Deploy and verify the actual failing route. SSR response must remain successful even if optional cache population is unavailable.
Make cache availability degrade performance, not correctness
This is the broader lesson from the incident.
A resilient SSR stack should have clear tiers:
Authoritative data
Required to produce the correct response.
Derived cache/snapshot
Used to reduce latency or database work.
Background observability
Logs/analytics/metrics that should not normally decide whether the user gets a page.
If a tier-three or tier-two helper can synchronously crash tier one, the dependency direction is wrong.
This connects directly to the D1 work I documented in How We Cut Cloudflare D1 Row Reads by 98.8%: caching is powerful when it reduces database load, but the cache layer has to fail safely.
One final trap: floating promises
If you start asynchronous work and neither await it nor register it with waitUntil, Cloudflare may not keep the Worker invocation alive long enough for it to complete reliably.
So this is not a good fix:
caches.default.put(key, response);
return pageResponse;
just because it avoids touching ctx.
The correct choices are:
- await the promise because correctness requires it;
- register it with
waitUntilbecause it is legitimate background work; - intentionally skip it because the capability is unavailable and the operation is optional.
Make the lifecycle explicit.
If your Cloudflare SSR app has production-only 500s around caching, background work or framework adapters, contact me or see my MVP development service. These bugs usually become straightforward once the runtime capability boundary is traced end to end.



