>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Cloudflare "Handler does not export a fetch() function"

Alex Rivera covers Cloudflare Workers and the serverless edge at C2CZ. Author page · All authors.

A Cloudflare Worker that answers a request with nothing fails in the least helpful way possible: the browser gets a bare 500 Internal Server Error, your code did not throw, and the only witness is one line in the runtime log — TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'. I lost most of a Tuesday afternoon to exactly this on a Hono/Workers API: every route worked in wrangler dev, one route 500′d in production, and the dashboard showed no stack trace because there was no exception — there was simply no response.

The trap is that this is not one bug. It is a family of four different mistakes, each of which produces a different runtime message, and only one of which looks like a crash. I rebuilt all of them on the real engine — workerd, the same binary Cloudflare runs at the edge — and recorded the exact string each one emits, what the client sees, and the smallest fix. Everything quoted below is pasted from that run, not from a forum: Workerd build 2026-09-11, compatibility date 2026-09-01, Node 26.5.1, Linux 6.17 / arm64.

TL;DR

  • A Workers fetch handler must return a Response, always, on every path. Returning nothing, returning undefined, or returning a value of any other type is a runtime-level type error, not a silent no-op.
  • Four distinct messages, measured on workerd:
    • No fetch() exported at all → Handler does not export a fetch() function. (with the hint line Received a FetchEvent but we lack a handler for FetchEvents. Did you remember to export a fetch() function?)
    • async fetch() that falls through a branch → TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.
    • Non-async fetch() that returns undefined or a non-Response value (a string, an object) → TypeError: Callback returned incorrect type; expected 'Promise'
    • Promise.resolve(undefined) / a resolved-to-nothing promise chain → the same Incorrect type for Promise line as the async fall-through
  • In all four cases the client sees 500 with the body Internal Server Error and no diagnostic of its own. The log line is the whole diagnostic.
  • The fix is structural, not a patch: end the router with an unconditional fallback return, and wrap the handler body so an exception cannot escape as a non-Response. Measured after the fix: /health → 200, wrong method → 405 with an Allow header, unknown path → 404, and zero uncaught exceptions in the server log.
  • An actual thrown exception is the easy case: it prints Error: <message> with a stack frame, and Cloudflare documents it behind Error 1101. The four failures above print no stack frame pointing at your bug, which is why they eat afternoons.
  • Related, already covered in this blog: the sibling failure where the handler never finishes at all (Error 1101 and the cross-request promise) and the generic Error 1101 walkthrough.

What the runtime is actually enforcing

Workers’ HTTP path has a hard contract: your module exports an entrypoint with a fetch() handler, that handler is called with a Request, and it must either return a Response or return a promise that settles to a Response. The runtime does not provide a default response, does not convert your value, and does not warn you. When the contract is not met, the io-context tears the request down and writes one line to the server log.

Those checks live in the runtime’s JS glue layer, which is why the messages are identical whether the failing Worker runs in wrangler dev, in Miniflare/Vitest, or on Cloudflare’s edge — it is the same engine in all three places. Here is the full mapping I measured, one workerd instance per case:

What the handler didClient seesLog line (verbatim)
Module exports no fetch()500 / Internal Server ErrorReceived a FetchEvent but we lack a handler for FetchEvents. Did you remember to export a fetch() function? then workerd/api/global-scope.c++:393: failed: remote.jsg.Error: Handler does not export a fetch() function.
async fetch(), one branch returns nothing500 / Internal Server ErrorTypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.
Non-async fetch() that falls through (undefined)500 / Internal Server ErrorTypeError: Callback returned incorrect type; expected 'Promise'
fetch() returns a string500 / Internal Server ErrorTypeError: Callback returned incorrect type; expected 'Promise'
fetch() returns Promise.resolve(undefined)500 / Internal Server ErrorTypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.
fetch() throws new Error("boom")500 / Internal Server ErrorError: boom from the handler + at Object.fetch (worker.js:4:11)

Read the last row against the four above it and the whole problem comes into focus: when your code throws, the log names your file and line. When your code returns the wrong shape, the log names a type constraint and nothing else. There is no stack frame, because nothing failed — the handler simply handed the runtime something that is not a Response.

The distinction matters for triage. If wrangler tail shows a stack trace, you have a bug inside a function. If it shows one of the four lines above, the bug is in the shape of your router: a missing return, a missing handler, or a value that never became a Response.

Reproduce all four in four minutes, without a Cloudflare account

You do not need to deploy to see these. workerd installs as a single binary from npm, unauthenticated, and runs a Cap’n Proto config that points one socket at one ES module. That is enough to reproduce every string in the table above and to prove a fix before you ship it.

$ mkdir -p edge-lab && cd edge-lab
$ npm install workerd
$ ./node_modules/.bin/workerd --version
workerd 2026-09-11

The config declares a service (your module) and a socket (the HTTP listener). Two field names are worth noting because they are the usual first-run failures: the module must be an ES module (esModule = embed "worker.js"), and the path inside embed is resolved relative to the config file, not to your shell’s working directory — I hit Couldn't read file for embed: workers/w8-fixed-router.js by generating the config into a subdirectory.

# config.capnp (Cap'n Proto)
using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [
    (name = "w", worker = (
      compatibilityDate = "2026-09-01",
      modules = [ (name = "worker.js", esModule = embed "worker.js") ],
    )),
  ],
  sockets = [
    (name = "s", address = "127.0.0.1:19461", http = (), service = "w"),
  ],
);

Run it with --verbose. This is not optional: at the default verbosity the runtime prints nothing for a wrong-shaped response, so a broken Worker looks like a healthy one.

$ ./node_modules/.bin/workerd serve --verbose config.capnp > workerd.log 2>&1 &
$ curl -s -m 5 -o /dev/null -w 'http_code=%{http_code}\n' http://127.0.0.1:19461/api
http_code=500
$ grep -i 'uncaught exception' workerd.log | head -2
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.

Cause 1: the module exports no fetch() at all

This is the failure that arrives when a Service Worker’s code is pasted into an ES-module Worker, or when the default export is a class-shaped object that only implements scheduled(). Nothing is wrong with the module syntactically, so the bundle uploads and deploys cleanly; the first HTTP request is when it dies.

// worker.js — WRONG: no fetch handler anywhere in the export
export default {
  async scheduled(event, env, ctx) {
    return;
  }
};

The runtime is explicit about it. Note that it prints two lines: a human hint and the canonical error. If you grep only for Error you will find the second and miss the sentence that actually tells you what to do.

client: http_code=500
Received a FetchEvent but we lack a handler for FetchEvents. Did you remember to export a fetch() function?
workerd/server/server.c++:6632: error: Uncaught exception: workerd/api/global-scope.c++:393: failed: remote.jsg.Error: Handler does not export a fetch() function.

The fix is one line, and the shape matters: fetch must be a property of the default export, and it should be declared async so that every path has to resolve to something.

// worker.js — Service Worker syntax is NOT interchangeable with this
// WRONG for a module Worker:
// addEventListener("fetch", (event) => event.respondWith(handle(event.request)));

// RIGHT: a module Worker exports an object with a fetch handler.
export default {
  async fetch(request, env, ctx) {
    return new Response("ok", { status: 200 });
  }
};

Cause 2: the async handler falls through a branch

This is the one that ships most often, and the code always looks innocent. You add an early-return guard for the path you were debugging, and forget that the function now has a route that never returns anything. In a typed codebase the compiler would have caught it; in a plain fetch() with if branches, nothing does.

// worker.js — WRONG: /health returns a Response, /api returns nothing at all
export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return new Response("ok", { status: 200 });
    }
    // no return for any other path
  }
};

An async function that falls off its last block returns a promise resolved with undefined, so the runtime reports the promise contract, not the missing return:

client: http_code=500
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.

Searching your codebase for that string is useless — it is not in your code. The productive move is to make fall-through impossible. Two habits do it: declare the return type, and finish the router with an explicit fallback (Cause 5 below shows the full shape). In TypeScript, async fetch(request: Request): Promise<Response> turns this exact bug into a compile error, which is the cheapest possible place to catch it:

// worker.ts — the same bug, caught at build time
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return new Response("ok", { status: 200 });
    }
    // error TS2366: Function lacks ending return statement and return type
    // does not include 'undefined'.
  }
};

Cause 3: the handler returns something that is not a Response

Returning a string, a plain object, or a JSON-serialisable array from fetch() feels like it should work, because Express and most Node frameworks do exactly that. Here it is a runtime type error. The message changes, which is why this failure gets misdiagnosed as a different problem: a non-async handler is checked as a callback that must produce a promise.

// worker.js — WRONG: a string is not a Response
export default {
  fetch(request) {
    return "hello from the edge";
  }
};

The runtime classifies that string as a bad callback result, so the message names a promise contract your code never mentioned:

client: http_code=500
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught; stack = TypeError: Callback returned incorrect type; expected 'Promise'

The same line appears when a non-async handler simply falls through — measured with the function body ending after an if block and no return anywhere. And the fix is not “wrap it in a promise”: it is to build the response object explicitly, with the headers you want on the wire, because the runtime will not invent a content type for you.

export default {
  async fetch(request) {
    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: {
        "content-type": "application/json;charset=UTF-8",
        "cache-control": "no-store",
        "x-content-type-options": "nosniff"
      }
    });
  }
};

If you write Workers regularly, prefer Response.json() and Response.redirect() over hand-rolled serialisation: they take the same care of the content type and the status text, and there is less surface for a typo to return a non-Response by accident. The rest of this article uses them.

Cause 4: the promise resolves to undefined

This is the sharpest version of the family, because the code often has a return in it — just not on the path that produced the value. Two shapes account for nearly all of it: a promise resolved explicitly with undefined, and a helper whose return was dropped one level up the call chain.

// worker.js — WRONG: a promise that resolves to undefined
export default {
  fetch(request) {
    return Promise.resolve(undefined);
  }
};

Nothing throws, so the log has no frame: the promise was resolved, and the resolved value was the wrong type.

client: http_code=500
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.

The second shape is the one I shipped. A cache helper returned the cached Response, and the caller awaited it but did not return it:

// worker.js — WRONG: the value is awaited, then dropped
async function fromCache(request, cache) {
  const hit = await cache.match(request);
  return hit;               // Response | undefined
}

export default {
  async fetch(request, env) {
    // await is not enough: the promise resolves to undefined when the key is cold,
    // and that undefined never becomes a Response.
    if (!new URL(request.url).searchParams.has("nocache")) {
      await fromCache(request, caches.default);
    }
    return new Response("ok");
  }
};

Note what makes this invisible in review: the code has a return, an await, and a happy path that works. It fails only for the request class whose cache entry is cold — in my case, exactly the first request after every deploy, which is also the request a synthetic monitor hits. The fix is not return await blindly either, since the helper legitimately returns undefined on a miss; the cache read has to be a branch that falls back:

export default {
  async fetch(request, env, ctx) {
    const useCache = request.method === "GET" && !new URL(request.url).searchParams.has("nocache");
    if (useCache) {
      const hit = await caches.default.match(request);
      if (hit) return hit;                 // explicit: only a real Response returns here
    }
    const response = Response.json({ ok: true }, {
      headers: { "cache-control": "public, max-age=30" }
    });
    if (useCache) ctx.waitUntil(caches.default.put(request, response.clone()));
    return response;
  }
};

That if (hit) is the whole article in miniature: a value that might be undefined must never be the handler’s last expression.

One caveat that the local lab surfaced, and that matters if you want to reproduce this yourself: under workerd serve the Cache API is only available when a cache service is bound, and the config schema shipped with this build exposes no such service — the config refuses to compile with Struct has no field named 'cache'. In wrangler dev and on a deployed Worker caches.default exists by default, so read the snippet above as scoped to those environments. The method gate is not environmental, though: caches.default.put() rejects a non-GET request with TypeError: Cannot cache response to non-GET request., which arrives as yet another uncaught exception — hence request.method === "GET" on both the read and the write.

For contrast: what a real thrown exception looks like

It is worth having the healthy failure in front of you, because the log signature is unmistakable and it tells you the opposite thing about where to look. A handler that throws produces the message, the message text, and a frame into your own bundle:

// worker.js — a genuine exception (the easy case)
export default {
  async fetch(request) {
    throw new Error("boom from the handler");
  }
};

A thrown exception is the one case where the log points straight into your own bundle:

client: http_code=500
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = Error: boom from the handler
    at Object.fetch (worker.js:4:11)

Cloudflare documents the edge-side symptom of an uncaught exception as Error 1101, “Worker threw exception”: the browser gets an error page and the exception appears in the Workers Logs, with the frame pointing at your source. Worked examples of that path are in the two 1101 posts linked at the top of this article. The difference to internalise: 1101 with a frame means “your code threw”; 1101 without a frame, or a bare 500 from the local runtime, means “your handler did not return a Response”.

One caveat, stated plainly: I measured all of the log lines above on workerd 2026-09-11 running locally. What Cloudflare’s edge adds on top is the error page rendering and dashboard plumbing — the exception strings come from the same engine, but the 1101 mapping itself is documented behaviour I am citing, not something I reproduced against a production zone.

The fix: a router where no branch can fall through

All four causes collapse into one structural rule. Put every route behind a handler that is typed to return a Response, and end it with an unconditional fallback. Then add one outer try/catch whose catch block also returns a Response, so an unexpected throw cannot escape as a non-Response either. This is the version I standardised on for Workers APIs behind a JSON contract:

interface Env {
  API_KEY: string;   // secret binding, never a hardcoded literal
}

const SECURITY_HEADERS: Record<string, string> = {
  "x-content-type-options": "nosniff",
  "x-frame-options": "DENY",
  "referrer-policy": "no-referrer",
};

function json(data: unknown, status = 200, extra: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      "content-type": "application/json;charset=UTF-8",
      "cache-control": "no-store",
      ...SECURITY_HEADERS,
      ...extra,
    },
  });
}

async function sha256(text: string): Promise {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return new Uint8Array(digest);
}

// Constant-time: both inputs become equal-length digests, then XOR-accumulate every byte.
async function timingSafeEqual(a: string, b: string): Promise {
  const [da, db] = [await sha256(a), await sha256(b)];
  let diff = 0;
  for (let i = 0; i < da.length; i++) diff |= da[i] ^ db[i];
  return diff === 0;
}

async function route(request: Request, env: Env): Promise {
  const { pathname } = new URL(request.url);

  if (pathname === "/health") {
    return json({ ok: true });
  }

  if (pathname === "/api/items") {
    if (request.method !== "GET") {
      return json({ error: "method_not_allowed" }, 405, { allow: "GET" });
    }
    const expected = env.API_KEY;
    if (typeof expected !== "string" || expected.length === 0) {
      // fail closed: an absent or empty secret must never authenticate anyone
      return json({ error: "server_misconfigured" }, 503);
    }
    const provided = request.headers.get("authorization") ?? "";
    if (!(await timingSafeEqual(provided, `Bearer ${expected}`))) {
      return json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
    }
    return json({ items: [] });
  }

  return json({ error: "not_found" }, 404);
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    try {
      // route() is typed Promise: TypeScript rejects a caller that
      // forgets to return it, and the fallback below covers the unchecked case.
      return await route(request, env);
    } catch (err) {
      const message = err instanceof Error ? err.message : "internal error";
      ctx.waitUntil(Promise.resolve(console.error("unhandled", message)));
      return json({ error: "internal_error" }, 500);
    }
  },
};

Every detail in that listing is deliberate, and each one closes a hole that produces a real failure. The outer try/catch returns a Response from the catch block, so a thrown error becomes a 500 body instead of an uncaught exception. The route() signature is Promise<Response> rather than a union with undefined, so the missing-return bug cannot type-check. The 404 fallback is the last statement of the function, after every branch, so no path through the router ends without a value. The auth branch fails closed before it compares anything, so an unset or empty secret is a 503 rather than a bypass. And the comparison itself runs over equal-length SHA-256 digests with an XOR accumulator, which keeps it constant-time and stops a length difference from leaking through timing.

If you use Hono instead of a hand-rolled router, the equivalent guard is app.notFound() plus app.onError(), both of which must return a Response — and export the app as export default { fetch: app.fetch } so the handler remains bound to the router. The failure mode if you skip onError is the exotic one: Hono catches the throw, but a custom error handler that returns nothing reintroduces the exact Incorrect type for Promise line, because the returned value is now undefined again.

Here is the JavaScript twin of that router, byte-for-byte the module I ran for every response quoted in the next section. The typed listing above and this one differ only in annotations — the control flow, the fallback and the error branch are identical.

const SECURITY_HEADERS = {
  "x-content-type-options": "nosniff",
  "x-frame-options": "DENY",
  "referrer-policy": "no-referrer",
};

function json(data, status = 200, extra = {}) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      "content-type": "application/json;charset=UTF-8",
      "cache-control": "no-store",
      ...SECURITY_HEADERS,
      ...extra,
    },
  });
}

async function sha256(text) {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return new Uint8Array(digest);
}

// Constant-time: both inputs become equal-length digests, then XOR-accumulate every byte.
async function timingSafeEqual(a, b) {
  const [da, db] = [await sha256(a), await sha256(b)];
  let diff = 0;
  for (let i = 0; i < da.length; i++) diff |= da[i] ^ db[i];
  return diff === 0;
}

async function route(request, env) {
  const { pathname } = new URL(request.url);

  if (pathname === "/health") {
    return json({ ok: true });
  }

  if (pathname === "/api/items") {
    if (request.method !== "GET") {
      return json({ error: "method_not_allowed" }, 405, { allow: "GET" });
    }
    const expected = env.API_KEY;
    if (typeof expected !== "string" || expected.length === 0) {
      // fail closed: an absent or empty secret must never authenticate anyone
      return json({ error: "server_misconfigured" }, 503);
    }
    const provided = request.headers.get("authorization") ?? "";
    if (!(await timingSafeEqual(provided, `Bearer ${expected}`))) {
      return json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
    }
    return json({ items: [] });
  }

  return json({ error: "not_found" }, 404);
}

export default {
  async fetch(request, env, ctx) {
    try {
      return await route(request, env);
    } catch (err) {
      const message = err instanceof Error ? err.message : "internal error";
      ctx.waitUntil(Promise.resolve(console.error("unhandled", message)));
      return json({ error: "internal_error" }, 500);
    }
  },
};

Verify the fix, then verify it again under load

A 200 on one route proves nothing here, because the bug is per-branch. Probe every branch shape the router can take — the happy path, the wrong method, a missing credential, an unconfigured secret, and an unknown path — and read the server log as well as the status codes. The API_KEY below is a workerd text binding, not a hardcoded literal. Measured against the module above, every response captured with curl -D:

RequestStatusHeaders of interestBody
GET /health200 OKContent-Length: 11, Content-Type: application/json;charset=UTF-8, Cache-Control: no-store, plus the three security headers{"ok":true}
POST /api/items405 Method Not AllowedAllow: GET, Content-Length: 30{"error":"method_not_allowed"}
GET /api/items (no header)401 UnauthorizedWWW-Authenticate: Bearer, Content-Length: 24{"error":"unauthorized"}
GET /api/items (Bearer nope)401 Unauthorizedsame as above{"error":"unauthorized"}
GET /api/items (correct bearer)200 OKContent-Length: 12{"items":[]}
Same module, no API_KEY binding at all, client sends Authorization: Bearer undefined503 Service UnavailableContent-Length: 32{"error":"server_misconfigured"}
GET /nope404 Not FoundContent-Length: 21, Content-Type: application/json;charset=UTF-8{"error":"not_found"}

That sixth row is not decoration. Before the fail-closed branch went in, the check was a plain !== against `Bearer ${env.API_KEY}`, and with the secret unset that expression evaluates to the literal string Bearer undefined — so a client sending exactly that header authenticated successfully against a Worker whose secret had never been configured. I reproduced it: 200 OK instead of 503. A missing secret is a deployment bug; it must not be an authentication bypass.

The acceptance criterion for this class of bug is not a status code — it is the log. After every probe above, the runtime had written nothing:

$ grep -cE 'uncaught exception' workerd.log
0

Zero uncaught exceptions, no matter which branch the traffic takes. A status-code-only smoke test will pass on a router that still 500s every third request class, because the runtime’s own 500 and your JSON 500 look identical to a monitor that only checks http_code.

On a deployed Worker, the same discipline applies through the log stream. Because the runtime prints nothing at default verbosity, run the tail explicitly and grep for the type-check strings rather than for Error:

$ npx wrangler tail --format json \
    | grep -E "did not resolve to 'Response'|incorrect type|does not export a fetch"

If that grep stays silent under production traffic, you have fixed the family. If it fires, the line already tells you which of the four causes you are looking at — no stack trace needed.

Debugging checklist

  • Does the module export fetch? Grep for export default and confirm it is an object with a fetch property, not an addEventListener("fetch", ...) Service Worker preamble.
    String: Handler does not export a fetch() function.
  • Does every branch return? Walk the router top to bottom and confirm the last statement of each path builds or returns a Response. Add the fallback return now, not after the next incident.
    String: TypeError: Incorrect type for Promise: the Promise did not resolve to 'Response'.
  • Is the handler async? A non-async handler is treated as a callback that must return a promise, and the message changes accordingly.
    String: TypeError: Callback returned incorrect type; expected 'Promise'
  • Could any returned value be undefined? Cache misses, Map.get(), array.find() and helpers declared to return T | undefined are the usual sources. Branch on them.
  • Is --verbose on? Locally, without it, these failures are invisible in the log. On the edge, tail the Worker while you reproduce.
  • Are you looking at the right 500? A thrown exception prints a stack frame into your bundle; a missing Response does not. That single observation separates “my function is broken” from “my router is incomplete”.

Last verified: 2026-09-19. All four failure modes, the fixes and the curl output above were executed against workerd 2026-09-11 (compatibility date 2026-09-01) on Linux 6.17/arm64 with Node 26.5.1; the lab is one Cap’n Proto config per case, eleven tiny worker modules and nine shell scripts, no Cloudflare account involved. The Error 1101 mapping is cited from Cloudflare’s documentation.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.