Avoiding CPU Time Limit Errors in Cloudflare Workers

Your Worker returns Error 1102: Worker exceeded CPU time limit, or your logs show Worker exceeded CPU time limit on a fraction of requests. The request that triggered it is almost never doing I/O — it is burning synchronous CPU: a tight loop over a large array, a JSON parse of a megabyte payload, a hash over every item, or a regex that backtracks. Cloudflare measures CPU time, not wall-clock time, and a single synchronous burst that crosses the budget kills the invocation.

This guide is part of Memory and CPU Limits Across Edge Providers. It explains exactly what the budget measures, then walks through offloading, chunking, yielding, and WASM so a heavy handler stays under the line.

Root cause: CPU time is synchronous work, not wall-clock time

The critical distinction the error rests on: time spent awaiting I/O does not count against the CPU budget. A Worker can await fetch() for two seconds and spend zero CPU time. What counts is the JavaScript that actually executes on the V8 isolate thread between awaits.

The reference budget: Cloudflare Workers allow about 10 ms of CPU on the free plan, and up to 30 seconds of CPU on paid plans (configurable). When a single synchronous span exceeds the limit, the runtime terminates the invocation with Error 1102. Because the limit is per-invocation CPU, the fix is never “make it faster overall” — it is “stop doing a long synchronous burst,” by either moving the work off the isolate, breaking it into chunks separated by awaits, or running it in WASM where it is cheaper.

Synchronous burst versus chunked work against the CPU budget One long synchronous span crosses the CPU limit and triggers Error 1102. Splitting it into chunks separated by awaits keeps every span under the budget. Single burst one synchronous span (over budget) CPU limit → 1102 Chunked + yields chunk chunk chunk chunk ↻ = await yields to the event loop
The same total work survives when split into chunks separated by awaits, since each span stays under the CPU budget.

Step 1: Confirm it is CPU, not wall-clock or subrequests

Before changing code, prove the diagnosis. Wrap the suspect section and log CPU-bound duration; if the gap between awaits is large, it is CPU.

// diagnose.ts — instrument the suspect span
export async function timed<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
  const start = Date.now();
  const result = await fn();
  const ms = Date.now() - start;
  if (ms > 8) console.warn(`[cpu] ${label} took ${ms}ms of mostly-synchronous work`);
  return result;
}

Run wrangler tail in production and watch which labelled span spikes. If the spike is around a synchronous loop or parse, you have the culprit. I/O-heavy spans will show large wall-clock time but are not the cause.

Step 2: Offload heavy work to a subrequest

The cleanest fix is to not do the work on the edge at all. Push CPU-heavy computation to an origin service or a separate Worker via fetch, turning a CPU-bound span into an I/O wait that costs no CPU budget.

// offload.ts — move CPU-heavy work behind a subrequest
export async function summarize(req: Request, env: { COMPUTE_URL: string }): Promise<Response> {
  const body = await req.text();

  // Instead of parsing + crunching a large payload on the isolate,
  // hand it to a compute service; the await is I/O, not CPU.
  const result = await fetch(env.COMPUTE_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body,
  });

  return new Response(result.body, {
    headers: { 'content-type': 'application/json' },
  });
}

Reserve the edge for routing, auth, and light transforms. Anything that loops over thousands of items belongs behind a subrequest.

Step 3: Chunk a hot loop and yield to the event loop

When the work must stay at the edge, split it into chunks and await between them. Each await returns control to the runtime, resetting the synchronous span so no single span crosses the budget.

// chunk.ts — process a large array in CPU-safe batches
export async function processInChunks<T, R>(
  items: T[],
  fn: (item: T) => R,
  chunkSize = 500,
): Promise<R[]> {
  const out: R[] = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    const slice = items.slice(i, i + chunkSize);
    for (const item of slice) out.push(fn(item));
    // Yield: ends the current synchronous span before the next chunk
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
  }
  return out;
}

Tune chunkSize down if you still see 1102 — smaller chunks mean shorter synchronous spans. The total CPU is unchanged, but it is now spread across spans the runtime tolerates.

Step 4: Move number-crunching to WebAssembly

For genuinely heavy math (hashing many items, image transforms, parsing binary formats) WASM runs several times faster than equivalent JavaScript, so the same work costs far less CPU budget. Compile once at module scope and call the export per request.

// wasm.ts — instantiate WASM once per isolate, call per request
import wasmModule from './crunch.wasm'; // Cloudflare supports importing .wasm

let instancePromise: Promise<WebAssembly.Instance> | null = null;

function getInstance(): Promise<WebAssembly.Instance> {
  instancePromise ??= WebAssembly.instantiate(wasmModule, {}).then((r) => r.instance);
  return instancePromise;
}

export async function crunch(input: number): Promise<number> {
  const instance = await getInstance();
  const compute = instance.exports.compute as (n: number) => number;
  return compute(input); // runs in WASM, a fraction of the JS CPU cost
}

Instantiating at module scope means the cost is paid once per isolate, not per request.

Configuration

On a paid plan you can raise the CPU limit explicitly. Set it only as high as you need — a high limit hides the real problem.

// wrangler.jsonc — Cloudflare Workers
{
  "name": "compute-edge",
  "main": "src/worker.ts",
  "compatibility_date": "2026-01-01",
  "limits": {
    "cpu_ms": 50
  }
}

Local vs production divergence

Concern wrangler dev (local) Production
CPU limit enforcement not enforced — heavy loops just run enforced; Error 1102 on overrun
Free vs paid budget unlimited locally ~10 ms (free) / up to 30 s (paid)
WASM speed fast on dev machine fast, but budget still applies
setTimeout(0) yield yields locally yields and resets the CPU span
Failure visibility none wrangler tail shows the 1102
Subrequest latency local/mocked real network, but costs no CPU

The trap: a handler that loops over 50,000 items runs fine in wrangler dev because no CPU limit is enforced locally, then throws 1102 in production. Test against a production-sized payload, not a toy fixture.

Validation with Vitest

You cannot reproduce the CPU limit in a unit test, but you can assert the chunking yields and that results are unchanged. The yield count is a proxy for “the synchronous span was broken up.”

// chunk.test.ts
import { describe, it, expect, vi } from 'vitest';
import { processInChunks } from './chunk';

describe('processInChunks', () => {
  it('produces the same result as a plain map', async () => {
    const items = Array.from({ length: 1200 }, (_, i) => i);
    const result = await processInChunks(items, (n) => n * 2, 500);
    expect(result).toEqual(items.map((n) => n * 2));
  });

  it('yields between chunks', async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
    await processInChunks([1, 2, 3, 4, 5, 6], (n) => n, 2);
    // 6 items / chunk size 2 = 3 chunks → 3 yields
    expect(setTimeoutSpy).toHaveBeenCalledTimes(3);
    setTimeoutSpy.mockRestore();
  });
});

Named pitfalls

  1. Blaming a slow fetch. I/O wait costs no CPU; it cannot trigger 1102. Fix: instrument to find the synchronous span — usually a loop or parse.
  2. One giant JSON.parse of a large body. Parsing megabytes is synchronous CPU. Fix: stream-parse, cap body size, or offload to a subrequest.
  3. Catastrophic regex backtracking. A pathological pattern burns CPU on certain inputs. Fix: rewrite the regex to avoid nested quantifiers, or bound input length.
  4. Chunk size too large. A 10,000-item chunk is still one long synchronous span. Fix: lower chunkSize until each span clears the budget.
  5. Raising cpu_ms instead of fixing the loop. Hides the cost and inflates your bill. Fix: offload or chunk first; raise the limit only for legitimately heavy paid workloads.

Production deployment checklist

  • Confirmed the spike is synchronous CPU, not I/O wait, via
  • Hot loops chunked with an await
  • JSON.parse
  • limits.cpu_ms

Frequently Asked Questions

Does waiting on fetch count toward the CPU limit?

No. Cloudflare measures CPU time, which is the JavaScript that actually executes on the isolate thread. Time spent awaiting I/O — fetch, KV reads — does not count. That is why offloading heavy work to a subrequest fixes Error 1102: it converts CPU work into an I/O wait.

What is the CPU budget on Cloudflare Workers?

Roughly 10 ms of CPU on the free plan and up to 30 seconds of CPU on paid plans, configurable via limits.cpu_ms. The limit is per invocation and applies to synchronous execution, not wall-clock time.

Why does my Worker pass locally but fail with 1102 in production?

wrangler dev does not enforce the CPU limit, so a heavy loop runs unbounded locally and throws Error 1102 only in production. Always test against a production-sized payload to surface the cost before deploying.

Does yielding with setTimeout(0) actually help?

Yes. The await returns control to the runtime, which ends the current synchronous span. The next chunk starts a fresh span, so no single span crosses the budget. Total CPU is unchanged, but it is spread across spans the runtime tolerates.