- Blog/
Five Traps I Hit on Cloudflare Workers in Three Months
I’ve built a handful of apps on Cloudflare Workers over the last three months — a cron-driven healthcheck tool, two different D1-backed web apps. All on the free tier. The platform is generally well designed, but I hit five behaviors that fall into the same category: gets a sentence in the docs, costs you hours in production. In order.
1. waitUntil() makes cron failures invisible
#
If you kick off the real work inside a scheduled() handler with
ctx.waitUntil(), Cloudflare marks the cron event “successful” even if
that promise rejects. The dashboard history stays green while the
worker silently does nothing.
The fix is simple: make scheduled() async and await the real work
directly. Then a thrown error actually lands in the cron event history
as failed.
export default {
async scheduled(event, env, ctx) {
await doTheActualWork(env); // not waitUntil
}
}
waitUntil exists to finish work outside the request-response cycle,
not to mean “ignore errors” — but in the cron context, that’s exactly
how it behaves.
2. Static Assets’ immutable headers #
If you try to mutate the headers on a response returned from
ASSETS.fetch() (something like c.res.headers.set(...)), you get a
TypeError: Can't modify immutable headers and a 500. Passthrough
asset responses come back immutable.
The fix is to re-wrap the response:
c.res = new Response(c.res.body, c.res);
Do that once and the headers become mutable. Anyone adding a security header to static assets in a middleware chain hits this sooner or later.
3. Workers Logs are off by default #
Unless you write "observability": { "enabled": true } in your
wrangler.toml, your production console.error calls go nowhere —
they’re only visible while a live wrangler tail is connected. So if
an error happened once in production and you weren’t watching, that log
line might as well have never existed.
Turning this on at first deploy is cheaper than losing an hour to “why are my logs empty.”
4. The 50-subrequest cap on the free tier #
You get 50 subrequests per invocation. If you’ve written a monitoring/fan-out worker doing two fetches per target, that caps you at roughly 20 targets per invocation. If you need to watch more than that, you need a deterministic batch per cron tick — trying all of them in one shot silently skips some targets, and it might not even throw.
5. PBKDF2 isn’t expensive — because it’s native #
A pleasant surprise on the auth side: crypto.subtle.deriveBits runs
600,000-iteration PBKDF2 in about 75ms locally in workerd, because the
work happens in the native crypto layer, not in V8. It doesn’t touch
the Workers 10ms CPU limit. The one gap: without nodejs_compat there’s
no crypto.timingSafeEqual, so you hand-roll the comparison with XOR.
Bottom line #
- Use
awaitinstead ofwaitUntilin cron handlers, or errors vanish silently. - Re-wrap static asset response headers before mutating them.
- Turn on Workers Logs at first deploy, don’t wait to remember.
- Batch around the 50-subrequest cap in fan-out workers.
- Don’t fear the CPU limit on PBKDF2 thanks to native crypto — the real
gap is
timingSafeEqual.
I do web app pentests on the side, including apps running on Cloudflare Workers. If the security side of the platform’s own footguns — auth bypass, SSRF, access control — is relevant to you: [email protected].