Workers are not Lambda, and the difference will bite you
Isolates instead of containers changes cold starts, billing, global state and I/O lifetimes. Four habits carried over from Lambda that quietly break on Cloudflare Workers.
Both platforms take a function, run it on someone else’s machine, and bill you per request. That similarity is where the useful comparison ends. Workers run in V8 isolates inside a shared process, not in a container or microVM of their own, and four things follow from that which regularly catch people out.
1. Global scope is shared across requests
This is the big one.
In a container-per-invocation model, module-level state is effectively per-instance and instances are usually handling one request at a time. On Workers, a single isolate serves many concurrent requests. Anything you hang off module scope is shared:
// DANGEROUS: leaks state between unrelated requests
let currentUser;
export default {
async fetch(request, env) {
currentUser = await authenticate(request, env);
// A second, concurrent request can overwrite currentUser
// between these two lines.
return new Response(`Hello ${currentUser.name}`);
},
};
Under load that returns another visitor’s name. Keep per-request state in the request scope, and pass it down as arguments:
export default {
async fetch(request, env) {
const user = await authenticate(request, env);
return new Response(`Hello ${user.name}`);
},
};
Module scope is still useful - for compiled regexes, parsed config, WASM modules - as long as what you put there is immutable and derived only from env or constants. Note also that isolates get evicted at the platform’s discretion, so module scope is a cache you cannot rely on, never a store.
2. You are billed for CPU time, not wall clock
A Lambda that spends 400 ms waiting on a slow database is a Lambda you paid 400 ms for. A Worker that does the same spends almost no CPU time, and the invocation is priced on the CPU it burned, not the time it was alive.
Practically:
- Fanning out to several slow upstreams in parallel is cheap.
- Tight loops, large JSON parses, crypto and image manipulation are what actually cost you.
- There is still a CPU-time ceiling per invocation, and it differs by plan. If you are near it, check the current limits rather than trusting a number in a blog post.
The mental shift is that “make it fast” and “make it cheap” are no longer the same optimisation.
3. There is no meaningful cold start, and no warming to do
Isolate startup is measured in fractions of a millisecond, and Cloudflare can start one while the TLS handshake is still in flight. The entire genre of Lambda workarounds - provisioned concurrency, scheduled pings to keep functions warm, bundling tricks to shave initialisation - has no equivalent here. If you ported a warmup cron, delete it.
What can be slow is your own top-level initialisation, since it runs on first request into each new isolate. Doing a synchronous 2 MB JSON parse at module load turns a non-problem into a problem.
4. I/O objects are tied to the request that created them
You cannot open a connection in one request and reuse it in another. A fetch body, a WebSocket, a stream - all of them belong to the request context they were created in, and using one outside it throws. This kills the classic Lambda pattern of a module-level connection pool.
For real databases, that is what Hyperdrive is for: it holds the pool on Cloudflare’s side, and your Worker gets a connection string it can use per request.
If you need work to continue after the response has been sent, that is an explicit API rather than a side effect:
export default {
async fetch(request, env, ctx) {
const response = await handle(request, env);
// Logging continues after the client has its bytes.
ctx.waitUntil(recordMetrics(request, env));
return response;
},
};
Without waitUntil, that promise may be cancelled the moment the response finishes.
The bit nobody enjoys
Workers are not Node. nodejs_compat covers a growing set of node: builtins and it is genuinely good now, but a dependency that reaches for the filesystem, spawns a process, or assumes a long-lived TCP socket will not run. Check that before you commit to a migration, not after - a Worker rewrite is a small job, and swapping out a native dependency you built the product on is not.
None of this makes Workers worse. It makes them different in ways that a Lambda-shaped codebase will not tell you about until it is in production.