All posts
No-Code & Tools

Cloudflare Worker SSR 500: “Cannot Read Properties of Undefined (reading 'waitUntil')”

A cache helper tried to call `executionContext.waitUntil()` when no execution context had reached that layer. The cache write was optional; the missing context turned it into an HTTP 500. Here is how to fix the boundary safely.

Elvis Onunwa02 October 20267 min readLast updated: 02 October 2026
Cloudflare Worker SSR 500: “Cannot Read Properties of Undefined (reading 'waitUntil')”

TL;DR

  • In Module Workers, Cloudflare passes `ctx` as the third argument to the top-level fetch handler, but framework adapters and helper layers can lose that context before a deep cache/logging function uses it.
  • If optional background work calls `executionContext.waitUntil()` unconditionally and the context is undefined, a non-essential cache write can turn a valid SSR response into HTTP 500.
  • Make context ownership explicit: pass it correctly, make optional helpers safe when it is absent, or use Cloudflare's direct `waitUntil` import where that runtime pattern fits.
  • Use `waitUntil` only for work that does not affect the response. If correctness depends on the operation, await it before returning instead of hiding it in background work.
On this page⌄

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:

  1. render the page;
  2. return the response;
  3. 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:

  1. Find the first application frame in the stack. Which helper touched .waitUntil?
  2. Inspect that function signature. Is the context marked optional, required or untyped?
  3. Trace backward to the Worker entry point. Where did ctx stop being forwarded?
  4. Inspect framework adapters. Does the deployed handler receive/pass the third fetch argument?
  5. Check alternate callers/tests. Who can call the helper without a Worker context?
  6. Classify the background task. Is it optional performance work or required business state?
  7. Choose explicit pass-through, safe optional handling or direct import. Do not paper over required state with optional chaining.
  8. Add a regression test. Rendering without a cache scheduling context should behave according to the contract.
  9. 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 waitUntil because 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.

Sources checked

Frequently asked questions

Questions, answered.

Why is `executionContext` undefined in my Cloudflare Worker?

Cloudflare supplies `ctx` to the Worker handler, but your framework adapter, wrapper or helper function may not forward it. Tests or alternate entry points can also call a helper without a context.

Can I just use optional chaining on `ctx?.waitUntil()`?

For genuinely optional background work such as a cache write, that can be a safe fallback if losing the write is acceptable. It is not appropriate when the operation is required for correctness or durable business state.

Can I call `waitUntil` without passing ctx through every helper?

Yes. Cloudflare added a direct `waitUntil` import from `cloudflare:workers` in 2025 so deeply nested Worker code can schedule background work without threading the execution context through multiple layers.

Should payment writes or booking confirmation use `waitUntil`?

Not when the customer-facing response depends on that state being durable. Cloudflare recommends `waitUntil` for work that does not affect the response, such as cache writes, analytics or logging.

NEED HELP WITH THE BUILD?

Turn what you learned here into a clearer website or MVP.

Share the goal, current stage, and constraints. I’ll help you identify the smallest responsible next step.

Continue reading

Related notes.