Skip to content

Engineering

Cloudflare Workers error tracking: the errors that still return 200

We deployed a Worker that fails in five different ways and watched what the client, Cloudflare and an error tracker each saw. Two of the five returned 200, and one of those left no trace anywhere.

Andrew Babo5 min read

Share
On this page
Cloudflare Workers error tracking: the errors that still return 200

An error tracker wrapped around a Cloudflare Worker reports what your handler throws, not what it hides. In our test, a promise rejected inside ctx.waitUntil returned 200 to the client and was marked ok by Cloudflare with the exception attached. Our own @belvador/cloudflare 0.4.0 did not report it either; 0.4.1 does.

Two cases still need your own code whatever wrapper you use: an error you catch and turn into a 500, and a promise nobody waits for. The rest of this post shows the five cases we tested and what each observer saw.

The test Worker#

We wrote one Worker with a route for each way a Worker commonly fails:

  • /throw: the fetch handler throws.
  • /caught-500: the handler catches the error and returns new Response(..., { status: 500 }).
  • /wait-until: the handler returns ok and hands Cloudflare a promise that rejects 50 ms later, through ctx.waitUntil.
  • /floating: the same rejection in a promise nobody awaits and nobody passes to waitUntil.
  • scheduled(): the cron handler throws.

We ran it two ways on 25 October 2026. Locally, both a plain build and a build wrapped in @belvador/cloudflare 0.4.0 ran in workerd through Miniflare 4.20260730.0, with the SDK sending to a receiver on 127.0.0.1. Then we deployed the plain build as a temporary Worker on our own account, attached a Tail to record Cloudflare's view of every invocation, and deleted the Worker afterwards. Each case ran three times and gave the same result each time.

Who saw which error#

Five ways a Worker fails, and whether the client, Cloudflare's Tail and an error-tracking wrapper saw each one
Lab 25 Oct 2026: same result in 3 of 3 runs for every case
CaseClient gotCloudflare (Tail)Wrapper (@belvador/cloudflare)
Handler throws500, Cloudflare's error pageexception, error listedreported
Caught, returns 500500, your bodyok, no errornothing
Rejected waitUntil200ok, error listednothing in 0.4.0; reported from 0.4.1
Floating promise200ok, no errornothing
scheduled() throws—exceptionreported

Three things in that table are worth a closer look.

A caught error is only a status code#

When your code catches an error and builds a 500 itself, nothing is thrown anywhere. Cloudflare records the invocation as ok, and a wrapper has no exception to report. All you have left is the number 500 in request logs. If you want that error tracked, call captureException(error) inside the catch, before you build the response. The Cloudflare SDK docs show that pattern.

waitUntil errors land in Cloudflare's logs, not in your tracker#

waitUntil lets work continue after the response; Cloudflare's context docs give it "up to 30 seconds after the response is sent or the client disconnects." The response is already gone by the time the promise rejects, so the client sees 200. Cloudflare did record the exception on the invocation, but the outcome stayed ok. A dashboard or alert that counts failed invocations would not count this one.

The wrapper we tested first did not report it either. That was a gap in our own SDK, not a rule about Workers. From 0.4.1, @belvador/cloudflare reports a rejected waitUntil promise as an unhandled error with the request's scope, and Cloudflare still receives the same rejection. We re-ran the case with 0.5.1 in local workerd on 26 October 2026: one event in 3 of 3 runs, and the client still got 200.

If your tracker does not do this, or you want the error marked as handled with your own context, catch inside the promise. This pattern was reported in 3 of 3 runs with both versions:

TypeScript
import { captureException, withBelvador } from "@belvador/cloudflare";export default withBelvador(() => ({}), {  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {    ctx.waitUntil(      sendReceipt(request).catch((error) => {        captureException(error); // the response is already 200; this is the only report      }),    );    return new Response("ok");  },});

Here is what Cloudflare's Tail recorded for that route, trimmed to the fields that matter:

Tail output for the waitUntil route: outcome ok, with the exception listed
The invocation is ok; the error is only in the exceptions list

A floating promise leaves nothing to look at#

The promise that was neither awaited nor passed to waitUntil produced no exception anywhere: not on the client, not in the Tail, not in the wrapper. From the outside we cannot even tell whether the code after the await ran. Cloudflare documents waitUntil as the way to keep work going after the response, so treat any other floating promise as work that may silently not happen. Most linters can flag them (no-floating-promises in typescript-eslint).

A checklist for a Worker you care about#

  1. Wrap the handlers. It catches the two cases that throw: fetch and scheduled. Queue, email and tail handlers are wrapped the same way.
  2. Report what you catch. Every catch that returns an error response should call captureException first.
  3. Make sure every waitUntil rejection is reported: by the wrapper (Belvador from 0.4.1) or by a .catch() of your own. Treat it like a background job, because that is what it is.
  4. Ban floating promises. Await them or pass them to waitUntil.
  5. Keep Cloudflare's own logs on. Workers Logs include "invocation logs, custom logs, errors, and uncaught exceptions", and Cloudflare suggests OpenTelemetry export, Logpush or Tail Workers to send them elsewhere. They are your second witness when the wrapper misses something.
  6. Watch time limits separately. A Worker that runs out of CPU time fails with Cloudflare's error 1102; a cron Worker has a 15-minute wall-time limit (limits). Neither is an exception in your code, so monitor the run itself, as in cron jobs that never finish.

Where Belvador fits#

@belvador/cloudflare wraps the handlers on your exported Worker, gives every invocation its own scope, reports what they throw and, from 0.4.1, rejected waitUntil promises, and re-throws so Cloudflare still sees the same failure. It cannot see an error your code catches and turns into a response, or a promise nobody waits for. Your response is returned unchanged, and sending happens after the response so it never slows the user down. The Cloudflare Workers page shows how it fits with traces and cron monitors, and the background work guide covers jobs that outlive the request.

Keep reading#

Part of the guide: Monitoring AI agents that run on serverless functions

All posts

Guides

Monitoring AI agents that run on serverless functions

An agent run on serverless is a chain of short invocations, each with its own time limit. Here is what to watch at every link, with three small experiments to show where the signal gets lost.

Andrew Babo6 min read

Read next in the docs

See your own production errors this way

Belvador runs on your own Hetzner server: errors, cron jobs, uptime and traces, with your data staying yours.