DEV Community

Cover image for Worker Exceeded CPU Time Limit? Split the Job to Fix It
Ronen Cypis
Ronen Cypis

Posted on Originally published at runhooks.app

Worker Exceeded CPU Time Limit? Split the Job to Fix It

Your Worker runs fine on small inputs. Then you point it at the full dataset and it dies mid-run with:

Error 1102: Worker exceeded CPU time limit
Enter fullscreen mode Exit fullscreen mode

Nothing's wrong with your logic — you've hit a per-invocation CPU ceiling. And the fix isn't a bigger limit. It's a smaller job.

Why Cloudflare Limits CPU Time

Cloudflare Workers run on shared infrastructure at the edge, so each invocation gets a bounded slice of CPU time — the time actually spent computing. Crucially, this is not wall-clock time: a Worker can await a slow subrequest for a while without penalty, because waiting on I/O doesn't burn CPU. What burns CPU is work — looping over tens of thousands of records, parsing a huge payload, hashing, transforming.

The exact budget depends on your plan and changes over time (as of 2026, check Cloudflare's current docs for the numbers). The free plan gives a small CPU budget per request; paid plans give far more and let you raise the ceiling in configuration. But every plan has a per-invocation cap, so any job whose CPU cost grows with your data will eventually cross it — a bigger plan just moves the wall further out.

The common thread: the CPU limit protects Cloudflare's shared edge, not your batch job. For request-sized work it's invisible. For "process everything" work, it's a hard stop.

The Fix: Split the Work Across Invocations

If the work is divisible — a bulk import, a mailing, a nightly sync, reindexing 100k rows — you don't need one long invocation. You need many short ones, each processing a batch that finishes comfortably under the CPU limit.

The pattern is a cursor plus repeated triggering:

export default {
  async fetch(request, env) {
    const BATCH = 500;
    const cursor = Number(new URL(request.url).searchParams.get('cursor') ?? 0);

    const rows = await getRows(env, cursor, BATCH);   // fetch a small batch
    for (const row of rows) await processRow(env, row); // stays under CPU limit

    const next = cursor + rows.length;
    const done = rows.length < BATCH;
    return Response.json({ processed: rows.length, next, done });
  },
};
Enter fullscreen mode Exit fullscreen mode

Each call does a bounded amount of CPU work and returns where to resume. Something on the outside just has to keep calling it — advancing the cursor — until done is true. That "something" is a scheduler.

Why a DIY Loop Isn't Enough

The obvious approach is a script that calls the Worker in a loop, or a laptop cron job:

  • Requires an always-on machine. A local loop dies when your laptop sleeps; a VPS means running a server to drive an edge Worker.
  • Fails silently. If a batch errors or times out, a naive loop either stops (leaving the job half-done) or barrels past the failure with no record.
  • No execution history. When the backlog stalls at row 45,000, you have no log of which batch failed or why.
  • No retries. A transient subrequest error kills a batch, and nothing retries it.

You'd end up rebuilding retries, logging, and alerting around the loop — a scheduler, minus the guarantees.

How Runhooks Drives the Batches

Runhooks is a scheduled HTTP execution service. Draining a large job is a two-minute setup:

  1. Create a job — name it "Reindex batch."
  2. Set the URL — your Worker endpoint, e.g. https://your-worker.workers.dev/.
  3. Set the schedule — frequently enough to drain the backlog, e.g. */2 * * * *.
  4. Enable retries — a failed batch retries instead of stalling the run.

What you get that a DIY loop doesn't:

  • Reliable cadence — batches fire on schedule until the work is done, without a machine of your own.
  • Automatic retries — a transient failure retries at 1s → 2s → 4s instead of dropping a batch.
  • Execution logs — every batch recorded with status, response, and duration, so you can watch the cursor advance and spot where it stalled.
  • Failure alerts — if batches start failing, you're notified immediately rather than discovering a half-processed dataset later.

Keep your batch logic small enough to stay under the CPU limit, and let Runhooks handle the "run it again until it's done" part.

When a Scheduler Can't Help

Be honest with yourself about the workload. Splitting only works when the job is divisible. If a single unit of work is itself too CPU-heavy — one massive cryptographic operation, an in-memory transform that can't be chunked, a computation that must complete in one pass — no amount of scheduling helps, because you can't make one invocation cheaper by calling it more often.

For genuinely indivisible heavy compute, reach for Cloudflare Queues or Durable Objects to restructure the work, or move that step to a runtime built for long-running jobs. A scheduler is the right tool for orchestrating divisible batches — not for extending a single expensive computation.

Get Started

The CPU limit isn't a wall you upgrade past — it's a nudge to size your jobs to the request:

  1. Rewrite the heavy job to process one bounded batch per invocation, with a cursor.
  2. Create a Runhooks account and trigger it on a schedule with retries and logs until the backlog drains.
  3. Build and preview your cron expression with the cron visualizer.

Frequently Asked Questions

What does "Worker exceeded CPU time limit" mean?

It means your Worker used more CPU time in a single invocation than its plan allows, so Cloudflare terminated it (Error 1102). CPU time is the time actually spent computing — not the wall-clock time spent waiting on network or subrequests. A Worker can wait a long time on I/O without a problem, but a tight loop over a large dataset burns CPU time fast and trips the limit.

How much CPU time does a Cloudflare Worker get?

It depends on your plan, and the limits change over time — check Cloudflare's current docs for exact numbers. The free plan allows only a small CPU budget per invocation, while paid plans allow far more and let you raise the ceiling via configuration. The key point is that every plan has a per-invocation CPU cap, so a single job that grows without bound will eventually hit it regardless of plan.

How do I process a large job without hitting the CPU limit?

Split the work so each invocation processes a small batch that finishes well under the CPU limit, then run the Worker repeatedly until the whole dataset is done. Track progress with a cursor (an offset, an ID, or a queue) so each run picks up where the last left off. An external scheduler like Runhooks triggers the Worker on a schedule, retries a failed batch, and logs each run so you can watch the backlog drain.

When can't a scheduler help with the CPU limit?

When the work is a single indivisible computation — one large cryptographic operation, an in-memory transform that can't be chunked, or anything that must complete in one pass. Splitting only helps divisible workloads like bulk imports, mailings, and syncs. For indivisible heavy compute, use Cloudflare Queues, Durable Objects, or move that step to a runtime built for long-running work.


Disclosure: I'm the founder of Runhooks, one of the tools mentioned in this article.

Top comments (0)