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!');
}
}
}
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!');
}
}
}
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!
}
}
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!
}
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!');
}
}
}
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();
}
}
}
}
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()
}
}
But I prefer separating them because:
- Smaller UI bundle = faster cold starts for users
- I can deploy job changes without touching the UI
- 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');
}
}
}
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
}
}
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
And you're off to the races.
Wrapping Up
What I learned from this:
-
Don't fight the platform. I wasted time trying to make
fetch()do something it wasn't designed for. - Read the limits. Understanding CPU vs. wall time saved me hours of debugging.
- 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)