Reader brief
What this note will resolve
Distinguish duplicate queue entries from one accepted BullMQ job being processed again
Reproduce repeated processing after an exception and after a worker loses its lock
Protect a database side effect with a durable operation key and an atomic transaction
Decide where deterministic job IDs help and where they leave a failure window
Useful if
- Backend engineers operating BullMQ workers that update a database or call an external API
- Technical leads reviewing whether a retryable background workflow is safe under partial failure
Concepts in scope
- At-least-once processing
- Worker locks
- Stalled jobs
- Retry attempts
- Idempotency keys
- Atomic transactions
- Queue deduplication
On this page
- The result in one table
- First identify what was duplicated
- Why BullMQ can process a job again
- A failed processor can be retried
- An active job can become stalled
- The controlled reproduction
- Test 1: throw after the side effect
- Test 2: terminate the worker after the write
- Test 3: submit the same operation twice
- The protection belongs at two layers
- Layer 1: suppress duplicate enqueueing
- Layer 2: make the business effect idempotent
- External APIs leave another failure window
- Why increasing the lock duration is not the fix
- What to log in production
- What this reproduction does not prove
- The operating rule
A BullMQ job can be added once and still reach your processor twice. That does not automatically mean BullMQ created a duplicate job. It can mean the queue is recovering work whose previous execution did not finish cleanly.
That distinction matters. If a worker writes to a database, sends a notification, or calls another service before it fails, a second execution can repeat the business effect even though the queue is behaving as designed.
I tested this in a NestJS 12 service using BullMQ 6.3.8, Redis 8.10.1, PostgreSQL 17.11, and Node.js 24.19.0. The lab forces a retry after a database write, kills a worker after the same write, and submits the same business operation twice. Each failure path then runs again with a durable operation key. The assertions run against real PostgreSQL and Redis services in CI.
The complete implementation is public in the NestJS BullMQ idempotency lab. It includes separate API and worker processes, a transactional outbox, durable command state, integration tests, operational endpoints, and the controlled failure runner used for the result table.
The queue may process an accepted job again. The handler must decide whether the business operation has already been applied.
The result in one table
| Failure path | Protection | Processor runs | Side effects | Final job state |
|---|---|---|---|---|
| Exception after the write | None | 2 | 2 | Completed |
| Exception after the write | Durable operation key | 2 | 1 | Completed |
| Worker exit after the write | None | 2 | 2 | Completed |
| Worker exit after the write | Durable operation key | 2 | 1 | Completed |
| Producer submits twice | None | 2 | 2 | Both completed |
| Producer submits twice | Deterministic job ID | 1 | 1 | One job accepted |
| Producer submits twice | Durable operation key | 2 | 1 | Both completed |
The important result is not that the processor ran once. It did not. The safe version allowed repeated processing but prevented a repeated business effect.
First identify what was duplicated
“The job ran twice” can describe several different failures. Checking the wrong layer usually leads to a fix that only hides the symptom.
| Observation | What actually happened | First place to inspect |
|---|---|---|
| Two different job IDs | The producer added two queue entries | Request handling, event consumers, producer retries |
| One job ID appears in two processor logs | The accepted job was processed again | Attempts, stalled events, worker lifecycle, lock renewal |
| One job ID produced two database rows | The side effect was not idempotent | Transaction boundary and business operation key |
| One processor run produced two log lines | The logging or event listener duplicated output | Logger transport and local versus global events |
Start by logging the BullMQ job ID and a separate business operation key. A queue
ID identifies BullMQ’s record. A business key identifies the action your system
must apply once, such as apply-credit-482, send-registration-email-731, or
sync-calendar-event-944.
Those identifiers answer different questions. You need both.
Why BullMQ can process a job again
There are two recovery paths worth understanding before changing any settings.
A failed processor can be retried
BullMQ moves a job to failed when its processor throws an error. When attempts
is greater than one, BullMQ can move that job back for another attempt according
to its backoff configuration. The behavior is documented in BullMQ’s
retry guide.
The dangerous window is simple:
- The handler commits a side effect.
- Something throws before the processor returns successfully.
- BullMQ retries the job.
- The handler commits the side effect again.
Retry policy controls when work is attempted again. It does not make the work safe to repeat.
An active job can become stalled
When a worker starts processing a job, BullMQ places a lock on it. The worker renews that lock while it is active. If the worker exits, loses connectivity, or keeps the Node.js event loop busy long enough to miss renewal, another worker cannot know whether the first process finished the business operation.
BullMQ’s stalled-job documentation explains that a stalled job can return to waiting and be processed by another worker. This is a recovery mechanism. Without it, a crashed worker could leave work stuck forever.
From the queue’s perspective, retrying is safer than losing the job. From the application’s perspective, that choice means the handler must tolerate another execution.
The controlled reproduction
The companion repository runs a separate NestJS API and worker, a PostgreSQL transactional outbox, BullMQ on Redis, and a durable credit ledger. The controlled runner inside the same repository isolates the exception, worker termination, and duplicate producer paths without replacing the production-shaped implementation.
The pinned environment is:
| Component | Version |
|---|---|
| Node.js | 24.19.0 |
| NestJS | 12.0.4 |
| BullMQ | 6.3.8 |
| ioredis | 6.0.0 |
| Redis | 8.10.1 |
| PostgreSQL | 17.11 |
Start from a clean clone and follow the README. After PostgreSQL and Redis are running, this command applies migrations, seeds the account, tests the complete API-to-worker path, and executes every controlled failure:
npm run verify:integration
The failure runner prints structured events and stops with a non-zero exit code if the expected execution and side-effect counts change. The repository also contains architecture decisions, an operations runbook, health and readiness checks, Prometheus metrics, OpenAPI documentation, CodeQL analysis, and a production container build.
Test 1: throw after the side effect
The unsafe worker writes the effect and then fails on its first attempt:
const worker = new Worker(
queueName,
async (job) => {
await writeBusinessEffect(job.data.operationKey);
if (job.attemptsMade === 0) {
throw new Error("controlled failure after side effect");
}
},
{ connection },
);
await queue.add("apply-credit", data, {
attempts: 2,
backoff: { type: "fixed", delay: 100 },
});
The first database write succeeds. The thrown error causes another attempt. The second execution reaches the same write again.
The structured log showed the same BullMQ job ID in both executions:
jobId=1 attemptsMade=0 execution=1 sideEffectInserted=true
jobId=1 attemptsMade=1 execution=2 sideEffectInserted=true
The job eventually completed, but the database contained two effects. A green queue status did not mean the business result was correct.
With the durable operation key in place, the processor still ran twice:
jobId=1 attemptsMade=0 execution=1 sideEffectInserted=true
jobId=1 attemptsMade=1 execution=2 sideEffectInserted=false
The second execution found that the operation had already been claimed and committed. It returned without writing the effect again.
Test 2: terminate the worker after the write
The second test removes the clean error path. A child worker writes the effect and exits immediately, before BullMQ can record successful completion.
The test uses a short lock window so the recovery is fast enough to run locally. This timing is a reproduction setting, not a recommended production setting.
The observed sequence was:
| Step | Observation |
|---|---|
| 1 | Worker A received job 1 |
| 2 | Worker A committed the database effect |
| 3 | Worker A exited without completing the BullMQ job |
| 4 | BullMQ emitted one stalled event |
| 5 | Worker B received job 1 |
| 6 | Worker B attempted the same business operation |
| 7 | The job completed |
Without protection, both workers wrote an effect. With the durable operation key, Worker B still processed the job but its insert was rejected as an existing operation. The final counts were two processor executions and one effect.
One detail from this test is easy to miss: the recovery execution still reported
attemptsMade=0. The job had stalled rather than failed through the normal retry
path. If monitoring only looks for an increased attempt count, it can miss this
kind of repeated processing. Record stalled events and worker lifecycle data as
well.
Test 3: submit the same operation twice
The first two tests accepted one job and processed it again. The third test asks a different question: what if the producer creates two jobs for the same business operation?
With default BullMQ IDs, the two calls returned job IDs 1 and 2. Both jobs ran
and both wrote an effect.
With one deterministic job ID, both producer calls returned
operation-producer-job-id. Only one queue job ran while that job record remained
in the queue.
await queue.add("send-notification", payload, {
jobId: `notification-${payload.registrationId}`,
});
BullMQ documents this behavior in its job ID guide. Adding a job with an existing ID is ignored while the existing job record is present. The same documentation also states an important limit: after a job is removed, the same ID can be added again.
A custom job ID is useful ingress protection. It does not replace an idempotent handler. It cannot stop one accepted job from being reprocessed after a failure, and retention settings affect how long its duplicate protection lasts.
The protection belongs at two layers
The tests support a layered design rather than one queue option presented as a complete fix.
Layer 1: suppress duplicate enqueueing
Use a deterministic job ID or BullMQ’s dedicated deduplication options when multiple producers can submit the same logical work.
Build the identifier from stable business data, not from request time or a new random value. Prefix numeric identifiers because BullMQ does not accept custom job IDs made only from digits. Avoid colons because BullMQ reserves that separator in job IDs.
This layer reduces unnecessary queue work. It is not the final correctness boundary.
Layer 2: make the business effect idempotent
The full implementation locks the command, claims a unique ledger operation, updates the account, and records the completed result in one PostgreSQL transaction. The central part is:
return this.database.withTransaction(async (client) => {
const command = await this.lockCommand(client, commandId);
if (command.status === "completed") {
return this.completedResult(command);
}
const ledger = await client.query(
`INSERT INTO credit_ledger
(operation_key, command_id, account_id, amount_cents, source_job_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (operation_key) DO NOTHING
RETURNING id`,
[
command.operation_key,
command.id,
command.account_id,
command.amount_cents,
sourceJobId,
],
);
if (ledger.rowCount === 0) {
throw new Error("Operation exists but its command is incomplete");
}
const account = await client.query(
`UPDATE credit_accounts
SET balance_cents = balance_cents + $2,
version = version + 1,
updated_at = now()
WHERE id = $1
RETURNING balance_cents::text`,
[command.account_id, command.amount_cents],
);
await client.query(
`UPDATE credit_commands
SET status = 'completed',
result = $2::jsonb,
completed_at = now(),
updated_at = now()
WHERE id = $1`,
[command.id, JSON.stringify(result)],
);
return result;
});
The unique constraint is necessary, but the transaction is the protection boundary. If the claim commits without the account update, a later attempt may skip work that never happened. If the account update commits without the claim, a later attempt may repeat it.
In MongoDB, the equivalent design normally uses a unique index on the operation key and a transaction when the marker and domain update span multiple documents. The syntax changes, but the atomic boundary does not.
BullMQ also recommends designing jobs to be idempotent and keeping them simple enough to retry safely in its idempotent-jobs pattern.
External APIs leave another failure window
A database transaction cannot atomically commit your local idempotency record and a request to an unrelated external API.
Consider this order:
- Call the payment, email, calendar, or notification provider.
- The provider accepts the request.
- The worker exits before the local completion record is committed.
- BullMQ processes the job again.
The local database does not know whether the provider completed step 2. Marking the operation complete before the API call creates the opposite problem: a crash can leave the marker present even though the API request was never sent.
Use the strongest option the provider and workflow support:
- Pass a stable idempotency key to the external API when it supports one.
- Store an outbox record in the same transaction as the local domain change.
- Reconcile provider state before retrying when the API exposes a safe lookup.
- Split a large job into smaller state transitions that can be resumed.
- Send uncertain cases to review instead of retrying blindly.
Do not hide this gap behind a Redis lock. The lock coordinates workers. It does not create a transaction across your database and another company’s API.
Why increasing the lock duration is not the fix
A longer lock duration may reduce false stalls for a known workload. Sandboxed processors can also help keep CPU-heavy work from blocking the main worker’s event loop. These are operational improvements, not correctness guarantees.
A process can still be terminated. A host can restart. A network can fail after the remote side has accepted a request. A deployment can interrupt work at the wrong instruction.
Tune lock and stall settings from observed execution time and worker health. Do not use them as proof that the handler will only run once.
What to log in production
The difference between a duplicate enqueue, a normal retry, and stalled recovery should be visible without reconstructing the incident from scattered strings.
I would log these fields for every processor start and finish:
logger.info({
event: "queue-job-started",
queue: job.queueName,
jobName: job.name,
jobId: job.id,
operationKey: job.data.operationKey,
attemptsMade: job.attemptsMade,
workerPid: process.pid,
});
Also record:
active,completed,failed, andstalledevents- whether the idempotency claim was inserted or already existed
- the error class and retry decision
- the worker instance or process ID
- timestamps for the business effect and queue completion
- the final state of jobs that reach the retry limit
Do not place sensitive payload data in these logs. The identifiers should be enough to join the timeline without exposing user information.
What this reproduction does not prove
This was a correctness test, not a throughput benchmark. It uses one PostgreSQL service, one Redis service, and controlled failure timing. It does not measure production latency, cluster failover, Redis persistence, network partitions, or the cost of the idempotency tables under sustained load.
It also does not prove that every report of a duplicated BullMQ job has the same cause. Duplicate producers, retry configuration, stalled workers, repeatable-job logic, application bugs, and duplicate logging need separate checks.
The reproduction proves a narrower point: once the same business operation can reach a handler more than once, a durable atomic idempotency boundary prevents a second database effect in the tested failure paths.
The operating rule
Clone the companion NestJS repository, run the complete path, and then change the failure timing or operation-key strategy to test the design against your own assumptions.
Use deterministic job IDs or deduplication to control what enters the queue. Use a durable business operation key to control what changes state. Keep the marker and the database effect in one transaction. Treat external calls as a separate consistency problem.
Retries are not the defect. They expose a handler that assumed one execution. Design the side effect so a second execution is boring.