mosaddik.dev
All posts

Message queues and background jobs explained, with a working example

Mosaddik Billah 6 min read

Software Engineering

A terminal screen processing lines of green output
Photo by Jake Walker on Unsplash
On this page
  1. The pieces
  2. What belongs in a background job
  3. A small working queue
  4. Jobs will run more than once
  5. Don’t lose jobs between the database and the queue
  6. When to use a real queue tool

When someone signs up, your app might send a welcome email, add them to a CRM, resize their profile photo and notify your team on Slack. If the sign-up request does all of that before answering, the visitor waits several seconds, and if the CRM is down, the sign-up fails. A background job fixes both: the request saves a note saying “this needs doing” and answers straight away, and a separate worker does the slow part.

The pieces#

  • Producer: the code that creates a job, usually inside a request handler.
  • Queue: where jobs wait. A database table, Redis, or a hosted service.
  • Worker: a separate process that takes jobs off the queue and runs them.
Visitor ──► Web server ──► Queue ──► Worker ──► Email service, CRM, Slack…
               │
               └──► "Thanks for signing up!" (instantly)

What you gain:

  • Fast responses. The visitor isn’t waiting for other companies’ servers.
  • Resilience. If the email service is down, the job waits and retries; the sign-up still worked.
  • Smoothing. A burst of 10,000 sign-ups becomes a queue the workers get through at a steady pace, instead of 10,000 simultaneous calls that trip someone’s rate limit.
  • Scaling. More work? Run more workers, without touching the web servers.

What belongs in a background job#

A good rule: anything slow, anything that calls another company’s service, and anything the visitor doesn’t need to see the result of right now.

  • Sending emails and SMS
  • Generating PDFs, invoices and reports
  • Resizing images and processing uploads
  • Syncing to CRMs, accounting software and spreadsheets
  • Processing incoming webhooks after acknowledging them
  • AI calls that take more than a few seconds

Keep in the request what the visitor needs immediately: checking their input, saving their order, telling them it worked.

A small working queue#

You don’t need new infrastructure to start. This queue lives in a database table. It’s tested with Node.js’s built-in SQLite; the same design works in PostgreSQL.

SQL
CREATE TABLE jobs (
  id         INTEGER PRIMARY KEY,
  type       TEXT NOT NULL,
  payload    TEXT NOT NULL,
  status     TEXT NOT NULL DEFAULT 'queued',  -- queued, running, done, failed
  attempts   INTEGER NOT NULL DEFAULT 0,
  run_after  INTEGER NOT NULL DEFAULT (unixepoch()),
  last_error TEXT
);

The producer adds a row and moves on:

JavaScript
function enqueue(type, payload) {
  db.prepare("INSERT INTO jobs (type, payload) VALUES (?, ?)").run(type, JSON.stringify(payload));
}

// In the sign-up handler, after saving the user:
enqueue("sendWelcomeEmail", { email: user.email });

The worker claims the next job that’s due, runs it, and records what happened:

JavaScript
const handlers = {
  async sendWelcomeEmail({ email }) {
    await emailService.send({ to: email, template: "welcome" });
  },
};

async function workOnce() {
  // Claim one job in a single statement, so two workers can't take the same one.
  const job = db.prepare(`
    UPDATE jobs SET status = 'running', attempts = attempts + 1
    WHERE id = (
      SELECT id FROM jobs
      WHERE status = 'queued' AND run_after <= unixepoch()
      ORDER BY id LIMIT 1
    )
    RETURNING *
  `).get();
  if (!job) return false;

  try {
    await handlers[job.type](JSON.parse(job.payload));
    db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?").run(job.id);
  } catch (err) {
    const giveUp = job.attempts >= 5;
    const delay = 2 ** job.attempts * 30; // 1 min, 2 min, 4 min...
    db.prepare(`
      UPDATE jobs SET status = ?, last_error = ?, run_after = unixepoch() + ?
      WHERE id = ?
    `).run(giveUp ? "failed" : "queued", err.message, delay, job.id);
  }
  return true;
}

// Run forever: work while there are jobs, then check again every second.
while (true) {
  if (!(await workOnce())) await new Promise((r) => setTimeout(r, 1000));
}

When a job fails, it goes back in the queue with a growing delay (exponential backoff): 1 minute, then 2, then 4. After 5 attempts it’s marked failed with its last error, so a person can look at it. That pile of failed jobs is what larger systems call a dead letter queue.

In PostgreSQL with several workers, claim jobs with SELECT ... FOR UPDATE SKIP LOCKED so workers skip rows another worker is already taking. You also need a way to recover jobs stuck in running if a worker crashes mid-job, for example by resetting any that have been running for longer than a timeout.

Jobs will run more than once#

This is the most important rule of queues. Almost all of them guarantee delivery at least once, not exactly once. A worker can finish sending the email and crash before marking the job done; the job is retried and the email goes twice.

So write every job to be safe to repeat, which is idempotency:

  • Before sending, check whether you already have (welcome_email_sent_at on the user).
  • Use INSERT ... ON CONFLICT DO NOTHING and unique keys instead of blind inserts.
  • Pass idempotency keys to external APIs that accept them, such as payment providers.

Don’t lose jobs between the database and the queue#

If you save an order to the database and then push a job to a separate queue (like Redis), a crash between the two leaves an order with no confirmation email. The database-table queue above avoids this: insert the order and the job in the same transaction, and either both exist or neither does. With a separate queue, the standard fix is the outbox pattern: write the job to an outbox table in the same transaction, and have a small process move rows from there to the queue.

When to use a real queue tool#

A database table is a fine queue for thousands of jobs an hour. Move to a dedicated tool when you need more throughput, scheduling features or a dashboard:

Tool What it is Good for
BullMQ A Node.js library on top of Redis Node apps that already have, or can add, Redis
Amazon SQS A hosted queue Apps on AWS; nothing to run yourself
RabbitMQ A message broker Complex routing between many services
Hosted job services (Inngest, Trigger.dev and similar) Queues, retries and schedules as a service Serverless apps with no long-running worker

For work that runs on a timetable rather than in response to an event, such as a nightly cleanup, use a scheduler instead; on Wix, that’s scheduled jobs with Velo.

Queues are one of the core tools in how to scale a web app. If you need emails, syncs or AI processing moved off the critical path of your site, I build that as part of custom API integration.

Comments