Designing a Background Job That Can Safely Run Twice

Designing a Background Job That Can Safely Run Twice

September 15, 2026
Every way a job ends up running twice. None of them are bugs.

Every queue you are likely to use guarantees at least once delivery. Not exactly once. That is not a limitation of the implementation; it is a consequence of the fact that a worker can do its work and then die before recording that it did.

So the question is not whether a job will run twice. It is what happens when it does — and for most jobs written without this in mind, the answer is a second email, a double charge, or a counter that is wrong forever.

Every way a job ends up running twice. None of them are bugs.
Every way a job ends up running twice. None of them are bugs.

How a job runs twice without anybody making a mistake

  • The worker is killed mid-job. A deploy, an out-of-memory kill, a container restart. The job was never acknowledged, so it returns to the queue.
  • The job exceeded its visibility timeout. The queue assumed it died and gave it to another worker — while the first is still running. Now two copies are running at the same time.
  • An exception after the side effect. The email was sent, then a database write failed and the job was retried from the beginning.
  • A manual retry. Somebody clears the failed queue at 2am to see what happens.
  • A duplicate dispatch. A user double-clicked, or a webhook arrived twice.

The second one deserves attention because it is the case people do not picture: two copies concurrently, not one after the other. Anything relying on “check then act” is unsafe there, and that is most naive idempotency code.

Pattern one: make the operation naturally repeatable

The best answer when it is available, because it needs no extra state at all. Some operations simply do not care how many times they happen.

<?php
// unsafe: two runs double it
$project->increment('total_seconds', $entry->duration);

// safe: two runs produce the same value
$project->update([
    'total_seconds' => $project->entries()->sum('duration'),
]);

The second version recomputes the answer from the source rather than adjusting a running total. It is slightly more expensive and it cannot be wrong, which is usually a good trade for anything that decides money.

The same shape applies broadly. Set rather than add. Generate the file at a deterministic path rather than a new name each run. Upsert rather than insert.

Pattern two: a unique constraint

Where the job creates something, let the database enforce that it exists once.

<?php
public function handle(): void
{
    $invoice = Invoice::firstOrCreate(
        ['organization_id' => $this->orgId, 'period' => $this->period],
        ['total' => $this->calculateTotal()]
    );

    // safe to reach here twice; the invoice is the same one
}

With a unique index on (organization_id, period), a second run finds the existing row. Without the index it is a race: two concurrent runs both find nothing and both insert.

That distinction is the whole point. Application-level checks are advisory; a database constraint is a guarantee. If the correctness of your idempotency depends on a SELECT returning nothing, it is not idempotent under concurrency — which is exactly the case the visibility timeout creates.

Pattern three: record that you did it

For work with no natural key, keep a ledger of completed operations.

<?php
public function handle(): void
{
    $key = "job:invoice-email:{$this->invoice->id}";

    if (! JobRun::create_if_absent($key)) {     // unique index on key
        return;                                  // already done
    }

    Mail::to($this->invoice->email)->send(new InvoiceMail($this->invoice));
}

The ordering question is unavoidable and worth deciding explicitly. Claim the key before the side effect and a crash between them means the email is never sent. Claim it after and a crash means it is sent twice.

Neither is free, so choose by consequence: for an email, sending twice is embarrassing and missing one is worse, so claim after. For a payment, charging twice is unacceptable, so claim before — and add a reconciliation job to catch the ones that were claimed but never completed.

That reconciliation job is what most implementations lack, and it is why “idempotent” systems still need somebody to look at a list occasionally.

Four patterns, and when each one is the right answer.
Four patterns, and when each one is the right answer.

Pattern four: an idempotency key from the caller

For anything triggered externally, let the caller define what counts as the same operation.

<?php
// the caller sends a key; the same key is the same request
$key = $request->header('Idempotency-Key');

$existing = ApiRequest::where('key', $key)->first();
if ($existing) {
    return response()->json($existing->response, $existing->status);
}

This is what payment providers do, and it is the right model whenever a client might retry. Note that it returns the original response rather than re-running anything — a retry should look identical to the caller, including the body.

Store the response with the key, and expire both after a day or so. Keeping them forever turns your idempotency table into your largest table.

Keep at most one non-idempotent effect per job, and put it last.
Keep at most one non-idempotent effect per job, and put it last.

The visibility timeout, and why jobs overlap

The overlapping-copies case is worth understanding properly, because it is the one that breaks otherwise-careful code and it is entirely configuration.

When a worker takes a job, the queue hides it for a configured period — the visibility timeout, or in Laravel the retry_after setting. If the job has not been acknowledged by then, the queue assumes the worker died and releases it to somebody else. It has no way to tell a dead worker from a slow one.

So a job that usually takes thirty seconds and occasionally takes four minutes, with a ninety-second timeout, will sometimes have two copies running concurrently on different machines. Nothing crashed and nothing was misconfigured in an obvious way.

  • Set the timeout comfortably above the worst case, not the average. Measure the slowest real run and multiply.
  • Set the worker’s own timeout below it, so the job is killed before the queue hands out a second copy.
  • Keep jobs short. A job that processes a thousand records is better as a thousand jobs, or as a batch that checkpoints — long jobs are the ones that trip this.

Even with all three correct, design for the overlap. The configuration reduces the frequency; the unique constraint is what makes it harmless.

Batches and chains

Two more places where retry semantics catch people out.

A batch retries individual jobs, so each member needs to be idempotent on its own. A batch that is “90% done” and gets a partial retry is normal operation, not an incident.

A chain stops at the first failure, so a retried chain re-runs from the failed job onward — but any earlier job whose effects were not idempotent has already happened once and will not be undone. If step two sends an email and step four fails, retrying the chain from step four is fine; retrying it from the beginning sends a second email.

The practical rule: put the reversible and idempotent work first in a chain, and the irreversible work last. Then a retry from anywhere is safe, and the chain is also easier to reason about at 2am.

Where a lock is the wrong tool

A common instinct is to wrap the job in a lock so two copies cannot run together. It helps with concurrency and it is not idempotency, and confusing the two produces a system that fails in a more confusing way.

  • A lock prevents simultaneous runs. It does nothing about a run that happens an hour later.
  • Locks expire. A job that outlives its lock has two copies again, and now neither knows.
  • A held lock after a crash blocks the work entirely until it times out.

Use a lock to reduce wasted work, by all means. Do not use it as the correctness mechanism. The correctness has to come from the database constraint or the ledger, both of which are still true after the process died.

The pieces that are never idempotent

Some effects cannot be made repeatable, and the answer is to move them rather than to solve them.

  • Sending email or SMS. There is no unsend. Use the ledger, and accept the rare duplicate over the rare loss.
  • Charging a card. Use the provider’s idempotency key — every serious one supports it — and never build your own for this.
  • Calling a third-party API that is not idempotent. Record the attempt before and the outcome after, and reconcile. There is no better answer.
  • Appending to a file. Write to a deterministic path instead, so a second run overwrites rather than duplicates.

The general rule: keep at most one non-idempotent effect per job, and put it at the end. A job that sends an email and then does five more things will, on retry, send the email again for each of those five failure points. Split it, and let the queue retry the parts that are safe.

Dispatching once is a separate problem

Everything above makes a job safe to run twice. There is a second question that is often confused with it: how do you stop the same job being queued twice in the first place?

They are different, and solving the dispatch side does not remove the need to solve the run side — because even a job queued exactly once can still run twice for all the reasons at the top of this article.

Still, a unique dispatch is worth having where the duplicate is pure waste. Most queue libraries offer it: a job marked unique will not be queued again while an identical one is pending. The key is the thing to be careful with — it must identify the operation, not the moment, so "invoice:{$id}:{$period}" rather than anything containing a timestamp.

And give it an expiry. A unique lock that is never released — because the worker died holding it — silently prevents the job from ever being queued again, which is a far more annoying failure than the duplicate it was preventing.

Scheduled jobs have the same problem

A nightly task is a job too, and the scheduler gives you the same guarantee — which is to say, no better than the queue.

Two overlapping runs of a nightly report happen for ordinary reasons: the previous night’s run is still going when tonight’s starts, a second application server has the same cron entry, or somebody ran it by hand to check something.

Handle it the same way. Give the schedule an overlap guard so a second copy does not start while the first is running, and make the task itself idempotent anyway, because the guard is a lock and locks expire. A monthly invoicing task keyed on (organization, period) with a unique constraint is safe however many times it is invoked, which is what lets somebody re-run it after a failure without a discussion.

Making it testable

The test is the same one for every pattern here and it is trivial to write, which is why it is surprising how rarely it exists:

<?php
public function test_running_twice_has_the_same_effect_as_once(): void
{
    $job = new GenerateMonthlyInvoice($this->org, '2026-09');

    $job->handle();
    $job->handle();          // again, deliberately

    $this->assertCount(1, Invoice::where('period', '2026-09')->get());
    Mail::assertSentTimes(InvoiceMail::class, 1);
}

Add it to every job that has a side effect. It costs four lines and it is the only thing that catches the regression when somebody changes an upsert into an insert six months from now.

For the concurrent case, run two copies in parallel processes against the same database. That is what finds the missing unique index, and nothing single-threaded will.

What the dead letter queue is for

However careful the design, some jobs will exhaust their retries. What happens to them is a decision, and the default — they go into a failed table nobody reads — is the wrong one.

Three things make the failed queue useful rather than a graveyard.

  • Alert on the rate, not the count. A steady handful of failures a week is normal. Forty in an hour is an incident, and it usually names itself: the same job class, the same exception.
  • Keep the payload and the exception together. Being able to see the arguments the job was given is the difference between diagnosing it and guessing.
  • Make retrying safe and obvious. If the jobs are idempotent — and that is the point of everything above — then clearing the failed queue is a routine action rather than a frightening one. Somebody should be able to do it at 2am without waking anybody.

That last point is the practical payoff of the whole article. In a system where jobs are safe to repeat, the response to almost any transient failure is “retry them”, and that is an operation anybody can perform. In a system where they are not, every failure needs the person who wrote the job.

A checklist for any new job

  1. What is the side effect? If there is none, it is already safe.
  2. Can it be expressed as set rather than add? That is the cheapest fix.
  3. Is there a natural unique key? Put a database constraint on it, not an application check.
  4. If not, what key identifies this operation? Use a ledger.
  5. Claim before or after — which failure do you prefer?
  6. Is there more than one non-idempotent effect? Split the job.
  7. Is there a test that runs it twice?

One last framing that helps when deciding how much of this a given job deserves: ask what a customer would see if it ran twice. A duplicate row in an internal report is a shrug. A duplicate invoice is a phone call. A duplicate payment is a refund, an apology and a loss of trust. Spend the effort in that order.

None of it is difficult. What makes it worth doing systematically is that the failure is silent: a job that runs twice usually produces plausible-looking wrong data, and nobody notices until a customer reconciles a number three months later.

Related: when transactions silently do nothing, and queues versus cron jobs.