[{"data":1,"prerenderedAt":15},["ShallowReactive",2],{"$f4AQCo-7Y2rtcH5GNFYdA48-1Jx0w-y7LTwPXa_b_SWM":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":12,"readingMinutes":13,"html":14},"bullmq","BullMQ in Production: The Settings That Bite","BullMQ works on the first try in development. These are the Redis and job settings that decide whether it still works in production, checked October 2026.","2026-10-17",[4,9,10,11],"redis","node","background-jobs",false,5,"\u003Cp>BullMQ is the queue most Node.js apps end up with once &quot;send the email after the request returns&quot; stops being a \u003Ccode>setTimeout\u003C/code>. It&#39;s good. You install it, add a \u003Ccode>Queue\u003C/code> and a \u003Ccode>Worker\u003C/code>, point both at Redis, and jobs start flowing on the first try. That&#39;s the trap, honestly: nothing in development tells you which defaults will hurt you later, because in development Redis never restarts, never fills up, and your laptop never gets a SIGTERM halfway through a job.\u003C/p>\n\u003Cp>This is the checklist I&#39;d want ticked before a BullMQ setup goes live. Versions as of October 17, 2026: \u003Ccode>bullmq\u003C/code> 6.3.15 on npm, and the BullMQ docs&#39; own \u003Ca href=\"https://docs.bullmq.io/guide/going-to-production\">production guide\u003C/a>, which is better than most people realise and gets read by almost nobody.\u003C/p>\n\u003Ch2>Redis first, because BullMQ trusts it completely\u003C/h2>\n\u003Cp>BullMQ keeps everything in Redis: the waiting list, the locks, the job data, the retry counters. So the Redis settings matter more than any BullMQ option.\u003C/p>\n\u003Cp>\u003Cstrong>\u003Ccode>maxmemory-policy\u003C/code> has to be \u003Ccode>noeviction\u003C/code>.\u003C/strong> This is the one that bites people on managed Redis. Redis is usually configured as a cache, which means that when it gets close to its memory limit it starts deleting keys to make room. For a cache that&#39;s the point. For a queue it means jobs, or worse, half of a job&#39;s bookkeeping, disappear without an error anywhere. The BullMQ docs are blunt that \u003Ccode>noeviction\u003C/code> is the only setting that guarantees correct behaviour. Check what your provider defaults to before you trust it with anything: AWS ElastiCache&#39;s default parameter group, for example, ships with \u003Ccode>volatile-lru\u003C/code>, so you need your own parameter group there.\u003C/p>\n\u003Cp>\u003Cstrong>Turn on persistence.\u003C/strong> A lot of hosted Redis plans don&#39;t persist to disk unless you ask. Restart the instance and every queued job is gone. The docs recommend AOF with a write every second, which is a sensible trade between safety and speed for almost any app.\u003C/p>\n\u003Cp>If BullMQ is sharing a Redis instance with your cache, that&#39;s usually where both of these go wrong at once, because the cache wants eviction and the queue can&#39;t survive it. Give the queue its own instance or at least its own database with its own policy. A small one is cheap.\u003C/p>\n\u003Ch2>Queue and Worker want opposite things from the connection\u003C/h2>\n\u003Cp>When Redis drops for ten seconds, you want the code that \u003Cem>adds\u003C/em> jobs to fail fast, so your API returns an error instead of hanging, and you want the workers to sit and wait patiently until Redis is back.\u003C/p>\n\u003Cp>BullMQ uses ioredis by default, and three options control that behaviour:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">import { Queue, Worker } from &#39;bullmq&#39;;\n\nconst queue = new Queue(&#39;emails&#39;, {\n  connection: { host, port, enableOfflineQueue: false },  // fail fast in the request path\n});\n\nconst worker = new Worker(&#39;emails&#39;, processEmail, {\n  connection: { host, port, maxRetriesPerRequest: null },  // wait forever for Redis\n});\n\u003C/code>\u003C/pre>\n\u003Cp>For workers, \u003Ccode>maxRetriesPerRequest\u003C/code> has to be \u003Ccode>null\u003C/code>. BullMQ sets that for you when it creates the connection, but if you pass in your own ioredis instance and forget it, you get workers that throw \u003Ccode>MaxRetriesPerRequestError\u003C/code> after a short Redis blip and stop picking up jobs. The default reconnect strategy is exponential backoff between 1 and 20 seconds, which is fine; just don&#39;t override it with something that gives up.\u003C/p>\n\u003Cp>And attach an \u003Ccode>error\u003C/code> listener to both. Without one, a connection problem becomes an unhandled error event, and you find out from the process dying.\u003C/p>\n\u003Ch2>Retries are off until you turn them on\u003C/h2>\n\u003Cp>A job that throws is moved to the failed set, once, and that&#39;s it (\u003Ca href=\"https://docs.bullmq.io/guide/retrying-failing-jobs\">the docs on retries\u003C/a>). No retry. People are often surprised by this because &quot;queue&quot; sounds like it should retry by itself.\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">await queue.add(&#39;welcome&#39;, { userId }, {\n  attempts: 5,\n  backoff: { type: &#39;exponential&#39;, delay: 2000 },\n});\n\u003C/code>\u003C/pre>\n\u003Cp>With exponential backoff and a 2 second delay, the retries come roughly 2, 4, 8 and 16 seconds apart, which is enough to ride out a flaky third-party API without hammering it. Without a \u003Ccode>backoff\u003C/code>, retries happen immediately, which against a rate-limited API is just five failures in a row. One more detail from the docs: throw real \u003Ccode>Error\u003C/code> objects in your processor, not strings. BullMQ expects an \u003Ccode>Error\u003C/code>.\u003C/p>\n\u003Cp>Also think about what a retry means for \u003Cem>your\u003C/em> job. If the job charges a card and then sends a receipt, and the receipt step fails, a retry runs the whole processor again. Retries make jobs at-least-once, so the work inside them needs to be safe to repeat.\u003C/p>\n\u003Ch2>Stalled jobs and the CPU\u003C/h2>\n\u003Cp>When a worker takes a job, BullMQ puts a lock on it and the worker keeps renewing that lock while it works. If the renewals stop, the job is marked stalled and handed to another worker. After too many stalls it moves to failed.\u003C/p>\n\u003Cp>Renewals stop for two reasons. The process died, which is fine and is exactly what the mechanism is for. Or the process is alive but so busy that the event loop never gets a chance to renew the lock: image resizing in pure JS, a giant \u003Ccode>JSON.parse\u003C/code>, a tight loop over a CSV. Then the same job runs twice, on two workers, at the same time. For CPU-heavy work, use \u003Ca href=\"https://docs.bullmq.io/guide/workers/sandboxed-processors\">sandboxed processors\u003C/a> (a separate process per job) or break the work into chunks that give the event loop room to breathe.\u003C/p>\n\u003Cimg src=\"/blog/bullmq/bullmq-job-lifecycle.webp\" alt=\"BullMQ job lifecycle: waiting to active to completed, failed jobs going to delayed for backoff retries, and a stalled lock sending the job back to waiting\" width=\"1600\" height=\"900\" loading=\"lazy\">\u003Ch2>Deploys: close the worker before the process exits\u003C/h2>\n\u003Cp>Every deploy kills your workers. If the process just exits, any job in progress is left with a lock nobody renews, and it gets picked up again as stalled, after about 30 seconds by default. Handle the signals and close the worker first:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">async function shutdown(signal) {\n  console.log(`${signal}: closing worker`);\n  await worker.close();   // stops taking jobs, waits for the active ones\n  process.exit(0);\n}\nprocess.on(&#39;SIGTERM&#39;, () =&gt; shutdown(&#39;SIGTERM&#39;));\nprocess.on(&#39;SIGINT&#39;, () =&gt; shutdown(&#39;SIGINT&#39;));\n\u003C/code>\u003C/pre>\n\u003Cp>systemd, PM2 and most container platforms send SIGTERM and then wait a few seconds before SIGKILL. If your jobs can run longer than that grace period, raise it (\u003Ccode>TimeoutStopSec=\u003C/code> in a systemd unit) or accept that the long ones will come back as stalled.\u003C/p>\n\u003Ch2>Old jobs pile up forever\u003C/h2>\n\u003Cp>By default every completed and every failed job stays in Redis forever. On a busy queue that&#39;s a slow memory leak, and with \u003Ccode>noeviction\u003C/code> set, Redis full means writes fail, which means your queue stops. Set limits when you add jobs:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">{ removeOnComplete: 1000, removeOnFail: 5000 }\n\u003C/code>\u003C/pre>\n\u003Cp>Keep more failed jobs than completed ones, since the failed ones are the ones you&#39;ll want to read.\u003C/p>\n\u003Cp>Two smaller things while you&#39;re in there. Job data is stored in Redis in plain text, so don&#39;t put tokens, passwords or card details in it; pass an ID and look the rest up inside the job. And if you still use repeatable jobs for cron-style work, the docs now point you to \u003Ca href=\"https://docs.bullmq.io/guide/job-schedulers\">Job Schedulers\u003C/a> (\u003Ccode>queue.upsertJobScheduler\u003C/code>, since 5.16.0), which don&#39;t create duplicates every time your app restarts and re-registers the schedule.\u003C/p>\n\u003Ch2>If you&#39;d rather someone checked it\u003C/h2>\n\u003Cp>None of this is exotic. It&#39;s a dozen settings, and the cost of getting them wrong is jobs that vanish quietly, which is the worst kind of bug because nobody files it. If you&#39;ve got a Node app with background jobs and you&#39;re not sure they&#39;d survive a Redis restart or a busy Monday, I&#39;m happy to look. The \u003Ca href=\"https://calendly.com/director-norsky/meeting\">first call is free\u003C/a>.\u003C/p>\n",1792197013084]