The 10ms CPU limit on the free Workers plan scares first-time readers. Ten milliseconds seems ridiculously short for anything useful. The consequence is that many teams immediately move up to the plan paid for the 30 seconds of CPU — without understanding that the measurement model is fundamentally different from a traditional server, and that most Workers have CPU left over even in the free 10ms. The 128MB memory limit, on the other hand, is systematically underestimated and is responsible for an entire category of silent failures in production.
How the CPU timer actually works
The Workers runtime measures "CPU time" — the time the JavaScript thread is actively executing code. The timer stops during any asynchronous I/O operation: await fetch(), await env.KV.get(), await db.query(), await env.BUCKET.get(). During these awaits, isolate is idle, and the timer does not advance.
The practical effect is great. A Worker that makes five fetch() sequential calls to external APIs, each taking 100ms of network latency, has a wall-clock time of 500ms but uses perhaps 6ms of CPU — just the serialization of the headers, the parsing of the response JSON, and the business logic between the calls. For most Workers that are essentially I/O orchestrators, the 10ms limit is generous.
What really uses CPU are synchronously intense operations: regex applied to large strings, objects with deep hierarchy or large arrays, cryptographic operations even when the API is asynchronous (the hashing work occurs on the CPU during execution), and base64 encoding/decoding on large binaries. A Worker that receives a 500KB JSON payload and does JSON.parse() on it will spend measurable CPU time on this operation — parsing is synchronous.
On the paid plan, the limit goes up to 30 seconds of CPU time, which is enough for computationally intensive use cases: compression, image generation, small model inference. But even on paid, CPU operations longer than a few seconds are a symptom of poor design for the edge — Workers were optimized for low latency, not heavy processing.
The CPU timer is not what kills Workers in production
What really takes down Workers in production without clear warning is memory. The 128MB limit seems reasonable until you understand what counts: the uncompressed script on the V8 heap, all the module closures that exist in the global scope since the start of the invocation, plus everything that is allocated during the ongoing request processing.
The script itself can be more consuming than you realize. A compressed 500KB Worker can take up 3-4MB after being decompressed and parsed by V8. Dependencies imported at module scope — validation libraries, parsers, SDKs — stay in memory as long as the isolate lives, even if they are not used in the current request. The global scope is shared between requests that the same isolate processes sequentially in the same PoP.
The allocation that most often triggers the limit is response.arrayBuffer() or request.arrayBuffer(). Calling this method on a 40MB response allocates 40MB of heap immediately. If the Worker then creates more data structures from this buffer — parsed objects, transformed copies — memory usage can exceed 128MB before processing ends. The runtime kills isolate at this point, the client receives an error 1101, and there is no stack trace — just a runtime error reported as a generic exception in wrangler tail.
Streaming as a memory survival strategy
The solution to the memory problem with large payloads is to never materialize the entire content as a buffer. Instead of response.arrayBuffer(), use response.body — which is an ReadableStream — and process the data in chunks with TransformStream.
The Workers streams API follows the WHATWG Streams specification, the same one available in modern browsers. A TransformStream has a writable and a readable: you connect the readable of the upstream response to the writable of the transform, and connect the readable of the transform to the response you send to the client. Chunks flow through the pipeline without ever having entire integers in memory at the same time.
This architecture has an important consequence: you can no longer read the entire content to make decisions that depend on the entire file before starting to respond. For cases where you need random access — processing a CSV that requires global ordering, for example — Workers is not the place to be. For transformations that operate chunk by chunk — recompression, text replacement, line filtering — the stream pipeline solves it without memory pressure.
For uploads going to R2, the binding directly accepts a ReadableStream:
await env.BUCKET.put(key, request.body, { httpMetadata }) — without buffering anything. The request body stream goes directly to R2 in chunks.
Operations that are surprising due to their CPU usage
Base64 encoding and decoding are CPU-intensive operations proportional to the data size — and the result takes up 33% more memory. If you are transporting binaries, it is worth asking whether encoding is necessary; Many uses of base64 are legacy HTTP limitations that no longer apply with fetch and native binary types.
Hashing operations via the Web Crypto API are asynchronous in signature — await crypto.subtle.digest('SHA-256', data) — but hashing consumes CPU proportional to the size of the data. A Worker that does HMAC-SHA256 on each request to verify a webhook is spending real CPU on it, irrelevant for small payloads but significant above a few hundred KB.
Regex on long strings can be surprisingly expensive. Patterns with catastrophic backtracking — multiple quantifiers nested over the same character set — can triple CPU time on strings of a few kilobytes. Measure with realistic strings, not 10-byte inputs that work in the test.
What to look for to detect resource pressure
wrangler tail returns cpuTime on each event — the CPU time measured in milliseconds for that invocation. Logging this value structuredly allows us to detect regressions: a deploy that increases CPU p99 from 3ms to 12ms means that some new synchronous operation has been introduced.
Memory usage is not directly exposed by invocation in tail. The way to detect memory pressure before exceeding the limit is to observe the behavior of the isolate: if the runtime starts creating new isolates more frequently than normal for the same PoP — visible as an increase in cold starts — it could be a sign that isolates are being discarded earlier by the garbage collector or due to memory pressure.
The ability for an isolate to be reused between requests in the same PoP is an important optimization: the cost of the cold start — decompressing the script, initializing V8, executing the higher-level module code — happens once, and subsequent requests reuse the already warmed isolate. Module closures that sit in memory between requests are the fastest caching mechanism available in Workers — faster than KV, faster than any subrequest.
Also read
- Cloudflare Workers in production: what changes after hello world
- D1 in production: performance, limits and what doesn’t scale alone
- Cloudflare Workers: Practical Guide to Serverless Edge Computing
- Durable Objects in production: what the bill will look like and the limits that surprise
- [WebAssembly at the edge: Why starting fast and isolated matters28
- Workers + D1 + KV + R2: composing bindings in the same service
