Obiora Igboanusi
Back to writing
April 3, 20262 min read

Moving Slow Work Off the Request Path with BullMQ and Redis

  • BullMQ
  • Redis
  • Node.js
  • Performance

On a consumer SaaS platform I worked on, some endpoints kicked off generation work that could take 20+ seconds. Holding an HTTP request open that long is a bad experience for users and a scaling hazard for servers: connections pile up, timeouts fire, and mobile clients retry — creating duplicate work.

The fix was architectural, not incremental: the request path only records intent; a worker does the work.

The shape of the system

Client → POST /generations → 202 Accepted { id }
                 │
                 └─→ BullMQ queue (Redis)
                          │
                     Worker process → updates row → done
Client → GET /generations/:id → { status, result? }

The API endpoint validates, writes a pending row to PostgreSQL, enqueues a job, and returns immediately with 202 Accepted. The client polls (or subscribes) for completion.

Queue configuration that matters

import { Queue, Worker } from "bullmq";

const queue = new Queue("generations", {
  connection: redis,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 5_000 },
    removeOnComplete: { age: 24 * 3600 },
    removeOnFail: { age: 7 * 24 * 3600 },
  },
});

A few decisions worth calling out:

  • Exponential backoff with 3 attempts. Most failures were transient (upstream rate limits). Retrying immediately would just hit the same limit.
  • Keep failed jobs for a week. When a user reports a problem, the failed job — with its stack trace and payload — is the incident report.
  • jobId set to the database row ID. If the same request somehow enqueues twice, BullMQ deduplicates.

Workers are just Node processes

const worker = new Worker(
  "generations",
  async (job) => {
    const result = await runGeneration(job.data);
    await db.generation.update({
      where: { id: job.data.id },
      data: { status: "complete", result },
    });
  },
  { connection: redis, concurrency: 5 }
);

Because workers run separately from the API, they scale independently. Heavy generation load never degrades login, billing, or browsing.

What testing looks like

The business logic lives in plain functions (runGeneration), so unit tests don't need Redis at all. Integration tests spin up a real queue against an ephemeral Redis and assert the full record-enqueue-process-update cycle with Jest and Supertest.

Results

  • Generation-heavy endpoints went from multi-second responses to under 100ms.
  • Retries became safe and automatic instead of user-visible errors.
  • One noisy feature stopped being able to take down the whole API.

If a request does anything slower than a database query, it probably belongs in a queue.