DEV Community

Cover image for Triggering Long Jobs in Cloudflare Workers
teaganga
teaganga

Posted on

Triggering Long Jobs in Cloudflare Workers

I recently hit a wall with Cloudflare Workers that I'm sure many of you have encountered: how do you trigger a long-running background job on demand?

It seems simple enough—just call a function from your API, right? But Workers aren't quite that straightforward, and I spent way too long trying to work around limitations that, it turns out, Cloudflare already solved for me.

Let me walk you through what I learned.


The Problem: My Job Was Too Long for HTTP

I had a Worker that handled my admin UI. One of the features was a button that kicked off a heavy background process—think scraping, data processing, batch operations, that kind of thing.

My first implementation was naive:

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      await runHeavyJob(); // 😬
      return new Response('Job complete!');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This worked fine in development. But in production? Timeouts. Everywhere.

Here's why that didn't work:

HTTP Requests Have Strict Limits

Plan CPU Time Wall Time
Free 10ms 30s
Workers Paid 50ms 30s
Business+ 30s 30s

My job needed more than 30 seconds of wall time, and I was burning through CPU time like crazy. Even on the paid plan, I kept hitting limits.

"No problem," I thought, "I'll just use ctx.waitUntil() to let it finish after the response!"

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      ctx.waitUntil(runHeavyJob()); // Still doesn't work! 😭
      return new Response('Job started!');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Turns out, waitUntil() doesn't extend the timeout—it just lets you do cleanup work after sending the response. The isolate still shuts down at the same time limit.


Why I Couldn't Just Use scheduled()

My next idea was: "Wait, I have a cron job that runs this successfully every night. Why not just... trigger that?"

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      // Can I just... call scheduled() somehow? 🤔
      await this.scheduled(); // Nope!
      return new Response('Done!');
    }
  },

  async scheduled(event, env, ctx) {
    await runHeavyJob(); // This works great!
  }
}
Enter fullscreen mode Exit fullscreen mode

This doesn't work. You can't call scheduled() from your code. Cloudflare's cron system is the only thing that can invoke it.

I tried a bunch of hacky workarounds:

  • Calling the Cloudflare API to trigger a cron (requires external auth, not instant)
  • Setting up webhooks to external services (defeats the purpose of Workers)
  • Storing a flag in KV and polling it every minute (works, but... gross)

All of these felt wrong.


The Lightbulb Moment: Queues Are Made For This

Then I discovered Cloudflare Queues, and everything clicked.

Queues give you a third type of invocation handler:

export default {
  async fetch(request, env, ctx) { ... },
  async scheduled(event, env, ctx) { ... },
  async queue(batch, env, ctx) { ... }, // 👈 This one!
}
Enter fullscreen mode Exit fullscreen mode

And here's the key difference:

Execution Limits by Handler Type

Handler CPU Time Best For
fetch() 10-50ms (most plans) Quick APIs, UI
scheduled() 30s Periodic jobs
queue() Unlimited Heavy processing

Queue handlers have no CPU time limit. Only wall time limits, which are measured in minutes, not seconds.


How I Actually Solved It

Here's my final architecture:

Worker 1: Admin UI (Producer)

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      // Enqueue a message
      await env.MY_QUEUE.send({
        type: 'heavy-job',
        triggeredBy: 'admin',
        timestamp: Date.now()
      });

      return new Response('Job queued!');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Worker 2: Job Runner (Consumer)

export default {
  async queue(batch, env, ctx) {
    for (const message of batch.messages) {
      const { type, triggeredBy } = message.body;

      if (type === 'heavy-job') {
        await runHeavyJob(); // Runs with unlimited CPU time! 🎉
        message.ack();
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This works beautifully because:

  • The UI Worker stays fast (just enqueues and returns)
  • The job Worker runs with unlimited CPU time
  • Queues handle retries automatically if something fails
  • I can scale the workers independently
  • Execution is nearly instant (no polling delay)

Important: Handlers Don't Compete for Resources

I initially thought combining fetch() and queue() in the same Worker would cause problems. Like, maybe a running queue job would slow down HTTP requests?

Nope. Each handler invocation runs in its own isolated execution context. They don't share CPU or memory at runtime.

What they DO share is:

  • The code bundle (larger bundles = slower cold starts)
  • The deployment (a bug in one handler affects the whole Worker)

So you CAN put all three handlers in one Worker:

export default {
  async fetch(request, env, ctx) {
    await env.MY_QUEUE.send({ type: 'job' });
    return new Response('Queued!');
  },

  async scheduled(event, env, ctx) {
    await env.MY_QUEUE.send({ type: 'cron-job' });
  },

  async queue(batch, env, ctx) {
    await runHeavyJob(); // This won't slow down fetch()
  }
}
Enter fullscreen mode Exit fullscreen mode

But I prefer separating them because:

  1. Smaller UI bundle = faster cold starts for users
  2. I can deploy job changes without touching the UI
  3. Cleaner separation of concerns

Other Options I Considered

Cron Polling

Set a flag in KV, then check it every minute with scheduled():

export default {
  async fetch(request, env, ctx) {
    await env.KV.put('pending-job', 'true');
    return new Response('Job will run soon');
  },

  async scheduled(event, env, ctx) {
    const pending = await env.KV.get('pending-job');
    if (pending) {
      await runHeavyJob();
      await env.KV.delete('pending-job');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's not instant. You're at the mercy of your cron interval (minimum 1 minute).

Durable Object Alarms

Durable Objects can set alarms that fire almost immediately:

export class JobRunner {
  async fetch(request) {
    await this.storage.setAlarm(Date.now() + 100); // 100ms
    return new Response('Alarm set');
  }

  async alarm() {
    await runHeavyJob(); // Runs in the DO context
  }
}
Enter fullscreen mode Exit fullscreen mode

This is actually pretty elegant, but requires setting up Durable Objects, which feels heavyweight for this use case.


My Recommendation

For on-demand long-running jobs: use Queues.

They're purpose-built for exactly this scenario:

  • Unlimited CPU time
  • Built-in retry logic
  • Simple API
  • Scales automatically
  • Near-instant execution

Setup is minimal:

# wrangler.toml
[[queues.producers]]
queue = "my-jobs"
binding = "MY_QUEUE"

[[queues.consumers]]
queue = "my-jobs"
max_batch_size = 10
max_batch_timeout = 30
Enter fullscreen mode Exit fullscreen mode

And you're off to the races.


Wrapping Up

What I learned from this:

  1. Don't fight the platform. I wasted time trying to make fetch() do something it wasn't designed for.
  2. Read the limits. Understanding CPU vs. wall time saved me hours of debugging.
  3. Queues are underrated. They're not just for distributed systems—they're perfect for background jobs in monoliths too.

If you're running into timeout issues with Workers, check if Queues solve your problem. They probably do.

Top comments (0)