Choosing a runtime
WORKER_RUNTIME selects the engine. The producers and the worker must agree: an application configured for one engine writes jobs that a worker configured for the other never sees, and the symptom is a document that stays in “processing” forever rather than an error.
The worker already requires
REDIS_URL on either runtime — it caches organization settings there. What bullmq adds is that the web application and API need it too: a producer that cannot reach Redis cannot enqueue a job at all. The worker also refuses to start against a Redis configured to evict keys, because an evicting instance can drop queued jobs silently, which looks exactly like work that was never submitted. Set maxmemory-policy noeviction.Sizing the worker
WORKER_CONCURRENCY sets how many jobs the worker runs at once. It applies to bullmq only; on temporal the equivalent limit is the SDK’s own and this variable does nothing.
It counts whole jobs, not steps. One document ingest is a single job made of roughly twenty sequential steps — download, parse, chunk, summarize, mask, embed, store. So
WORKER_CONCURRENCY=20 means twenty documents at a time, not twenty operations.
That distinction is the one worth getting right, because it decides what a bulk upload feels like:
.env
What we measured
On 2026-09-16 we ran 416 real ingests of ~500-word documents across both runtimes on one machine, against real embedding, summary and scoring providers. With twenty documents uploaded at once, measured per file from the moment the job was enqueued:
Read the last column first: at 10, half the batch spent eighteen seconds waiting for a free slot rather than being processed. That measurement is why the default is 20 — it was 10 until this run compared the two. The parsing and embedding times themselves were identical in all three configurations. The difference is the concurrency ceiling, not the engine — with a single document,
bullmq is the faster of the two (6.9s against 10.0s).
How to pick a number
- Start from the largest burst you expect — a customer’s initial import, a folder re-index, a Google Drive sync.
- Raise
WORKER_CONCURRENCYto roughly that number, then check your model provider’s rate limits. Every concurrent ingest issues embedding and summary calls; the ceiling that matters is usually the provider’s, not your worker’s. - Add worker replicas rather than raising the number indefinitely.
WORKER_CONCURRENCYis per worker process, so three replicas at 20 give you 60 documents in flight.
Watching the queues
Onbullmq, the worker can serve a queue dashboard showing waiting, active, completed and failed jobs, with each job’s payload and failure stack.
It is off unless both credentials are set, because it shows every job’s payload. Do not expose the port publicly.
Switching runtimes on a running deployment
A job already in flight belongs to the engine that accepted it. ChangingWORKER_RUNTIME orphans it: the new engine has never heard of it, so its document sits in “processing” until something re-indexes it.
- Stop the application, so no new jobs are produced.
- Let the worker finish everything it holds — not only documents. A website scrape, a document generation, a re-index and a nightly maintenance run are orphaned by the switch exactly as an ingest is, and only the ingest leaves a visible “processing” state behind. Check the old engine’s own view (the Temporal UI, or the queue dashboard) and wait until nothing is waiting or active on any queue.
- Change
WORKER_RUNTIMEon the worker and on the application. - Start both, and confirm the two nightly schedules exist on the new engine.