Migrating production infrastructure? Get up to ₹10,000 in migration credits. Apply now
voroaBlog
Background Workers

Background Jobs Explained: Queues, Workers and Retries

· · 6 min read

Background jobs explained, how queues, workers and retries fit together

Background jobs are pieces of work your app does outside the web request: sending an email, resizing an image, generating a PDF report. The request saves a small note saying “do this”, answers the user at once, and a separate process does the work a moment later.

This guide explains how that works, when it is worth it, and the one rule most teams learn the hard way: a job can run more than once. We measured both the speed-up and the double run with a real queue.

On this page

Advertisement

Why move work out of the request?

Users wait for every millisecond of a web request. If signing up sends a welcome email through a provider that takes 2 seconds, the sign-up page takes 2 seconds too. If the provider is down, sign-up fails.

Put the email on a queue instead and the request only waits for the enqueue. With BullMQ 6.7 on a local Redis, adding a job took 0.65 ms at the median and 1.44 ms at the 99th percentile, over 200 jobs. Over a network, add one round trip.

Use a background job when the work is slow, can fail and be retried, or the user does not need the result in the same response.

The three parts: producer, queue, worker

Diagram: a web request (the producer) adds a job to a queue in about a millisecond and replies to the user; a separate worker process takes the job from the queue, does the slow work, and marks it done or retries it

  • Producer: your web app. It adds a job with the data needed, such as a user ID, not the whole user.
  • Queue: where jobs wait. Usually Redis or a Postgres table, so jobs survive a restart of your app.
  • Worker: a separate process that takes jobs one by one, runs them, and marks each done or failed.

The worker is a long-running program with no web address. It is deployed separately from the web app, so a slow job never blocks a page, and you can run more workers when the queue grows.

Triggered jobs and scheduled jobs

Jobs start in two ways:

Type Started by Examples
Triggered Something a user or system did Welcome email, image resize, webhook processing
Scheduled The clock Nightly report, hourly cleanup, monthly invoices

Triggered jobs need a queue and a worker. Scheduled jobs can be a plain cron job that starts, works and exits; our guide to what a cron job is covers that side. Many queues also accept delayed and repeating jobs, so one worker can handle both.

A crashed worker means a second run

We started a job that takes 10 seconds, then killed the worker process 3 seconds in, the way an out-of-memory kill or a deploy would, and started a second worker right after the kill.

Time Event
0.8 s Worker A starts the job (attempt 1)
3.5 s Worker A is killed mid-job
64.5 s Worker B starts the same job again (attempt 2, marked stalled once)
74.5 s Worker B completes it

BullMQ noticed the dead worker’s lock had expired and handed the job out again, about a minute later with default settings. That is the right behaviour, because the work was never finished. But the first 3 seconds of the job had already happened.

If those 3 seconds sent an email or charged a card, the customer now gets it twice. Every queue that retries works this way. It is called at-least-once delivery, and it is why the first rule below matters.

Rules for jobs that survive production

1. Make jobs idempotent. Running a job twice must do no extra harm. Record that the email was sent, or use a unique key with your payment provider, and check it before acting.

2. Pass IDs, not objects. Put userId: 42 in the job, and load fresh data when the job runs. The user may have changed their email since the job was queued.

3. Retry with backoff, then stop. Retry a failed job a few times with growing delays, then move it to a failed list a person can look at. Retrying forever hides the problem.

4. Keep jobs short. One long job holds a worker and is the most likely to be cut off by a deploy. Split a big import into many small jobs.

5. Watch the queue length. A queue that keeps growing means workers cannot keep up. That is your signal to add workers or speed the job up.

Choosing a job queue

Language Common choice Backed by
Node.js BullMQ Redis
Python Celery, Dramatiq (Redis or RabbitMQ); RQ (Redis) Redis or RabbitMQ
Ruby Sidekiq Redis
Already on Postgres pg-boss (Node), River (Go), Solid Queue (Rails) Postgres

Redis-backed queues are the most common and fast; Redis lists are the classic building block. A Postgres queue means one less service: workers take jobs with FOR UPDATE SKIP LOCKED, described in the PostgreSQL SELECT documentation, so two workers never take the same row.

We tested the two most common in depth: BullMQ with Redis and Celery with Redis, including how many Redis commands an idle worker sends.

Running background jobs on Voroa

On Voroa, a worker is a Background Worker: built from your GitHub repository, with no web address, restarted if it exits. Run your web app as a web service, the worker beside it, and a managed Redis or Postgres database as the queue, all in Mumbai.

A program that finishes and exits belongs in a Cron Job instead, or Voroa will keep restarting it. The guide worker or cron job helps you pick, and deploying a background worker has the steps. Plans and sizes are on the background workers page.

FAQ

What is a background job? Work your app does outside the web request, in a separate process, such as sending email or generating a report. The user gets a response without waiting for it.

What is the difference between a background job and a cron job? A background job is usually triggered by an event and picked up by a worker. A cron job is started by the clock. Many apps use both.

Can a background job run twice? Yes. If a worker crashes mid-job, the queue hands the job out again. In our test the second run started about a minute after the crash. Make jobs safe to repeat.

Do I need Redis for background jobs? No. Redis is the most common queue, but a Postgres table with SKIP LOCKED works well at small and medium scale.

How many workers do I need? Start with one. Add more when the queue length keeps growing or jobs wait too long to start.

Measured with BullMQ 6.7.1 on a local Redis on 7 November 2026. Found something out of date? Write to [email protected].

Advertisement