← Writing

Hybrid Cloudflare: request path on Workers, heavy consumers on a cheap box

Cloudflare Queues supports HTTP pull consumers — which means your queue workers don't have to live in a 128 MB Worker at all. How we moved billing and evaluation consumers to Node processes on a Hetzner server, and what stayed behind.

On this page

There’s a version of the Cloudflare pitch where everything lives in Workers: API, crons, queue consumers, all of it at the edge. I ran an AI-agent platform that way as fractional CTO, and I’ve since taken a chunk of it apart — deliberately.

The pattern we landed on: the request path stays on Workers; heavy background consumers run as plain Node processes on a cheap Hetzner box, managed by Coolify. Cloudflare has a first-class mechanism that makes this possible, and not enough people seem to know it exists.

Why the consumers had to leave

Two forces pushed the background work out of the Worker.

Memory. A Worker isolate gets 128 MB, and on our schema each concurrent Prisma client cost ~3.81 MB of WASM memory that is never reclaimed. Queue consumers and cron handlers shared isolates with interactive dashboard traffic — every overlapping batch permanently raised the memory floor of whichever isolate ran it. This was one thread of the worker memory-kill incident that produced 11,058 kills in a week.

Transactions. Batch work wants real database transactions. Through the connection proxy Workers require, those started failing under load with Prisma’s P2028 — “Unable to start a transaction.” Billing work that retries on timeout is not a place you want ambiguity.

Neither problem is a bug. They’re the platform telling you this workload is the wrong shape for it.

The mechanism: HTTP pull consumers

Cloudflare Queues has two consumer modes. The default is push: a Worker is bound to the queue and invoked with batches. The alternative is HTTP pull: any process, anywhere, polls the queue over HTTPS with an API token, receives a batch, processes it, and acknowledges.

The constraint that matters: a queue has exactly one consumer mode at a time. Migrating is explicit:

wrangler queues consumer worker remove <queue> <worker>
wrangler queues consumer http add <queue>

From that moment the Worker stops receiving batches and your external process pulls them instead. Messages queued in between simply wait — the cutover is safe by default.

The producer side doesn’t change at all. The API Worker still enqueues with the same binding it always had. Only consumption moves.

The receiving end

The consumers became small Node services deployed with Coolify onto a Hetzner box — the same self-hosted layer I run everything else on. Each one is a loop: pull a batch, process with a real Postgres connection pool, ack, repeat. A health endpoint reports degraded states so the platform’s monitoring can see a stalled loop.

Two of them, with the configurations that mattered:

  • Usage-billing consumer — batch 10, concurrency 4, DB pool 10. Billing math in real transactions against a direct connection, retried without a proxy in the middle.
  • Evaluation consumer — batch 5, concurrency 2, DB pool 5, visibility timeout 300 s, because each message involves slow AI-model calls and the queue shouldn’t redeliver mid-run.

Note the shape of those numbers: pool ≥ concurrency, always, with headroom. On a box you control, that’s a config line — not a fight with a platform limit.

The trap to avoid: duplicated business logic

The lazy version of this migration copies the billing code into the new service. Six months later the Worker and the consumer disagree about rounding, and finance notices before you do.

We instead imported the shared logic directly from the API package — the consumers depend on the same modules the Worker uses, built from the same monorepo. One implementation of the billing rules, two runtimes executing it. The consumers’ Docker builds install the API package as a workspace dependency, so a change to billing logic ships to both sides from one commit.

If your repo layout makes this hard, fix the repo layout before doing the migration. Duplicated billing logic is a worse problem than the one you’re solving.

What stays on Workers

This isn’t an exit from Cloudflare — most of the platform still runs there, on purpose:

  • The request path. Auth, CRUD, webhooks — request-shaped work with small memory needs is exactly what Workers are best at, deployed globally for free.
  • Queue producers. Enqueueing is cheap and belongs next to the request that triggers it.
  • Static delivery and the edge plumbing — Pages, R2, DNS, tunnels.

The dividing line is simple to state: if it finishes in milliseconds and fits in kilobytes, it’s a Worker. If it holds connections, wants transactions, or eats memory, it’s a container on the box. The platform boundary follows the workload shape, not the vendor brochure.

The economics

The whole consumer fleet fits on a single cheap Hetzner server that also runs other services — the box costs less per month than a single seat of most SaaS tools. Meanwhile the request path keeps Cloudflare’s free-tier economics and global distribution.

Hybrid sounds like a compromise. In practice it’s just using each platform for the thing it’s actually good at — and the pull-consumer mode is the hinge that makes the whole arrangement work.

← All writing Book a call →
Book a call → WhatsApp