Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

At-Least-Once Delivery: Why Your Queue Will Run Some Jobs Twice

About Post

A worker picks up a job: "send the payment receipt". It sends the email. Then, a split second before it can tell the queue "done", the server restarts for a deploy.

The queue never heard back. As far as it knows, the job was lost. So, doing exactly what it promised, it hands the job to another worker. The tenant gets two receipts.

Nothing was broken here. No bug, no misconfiguration. This is the queue keeping its promise, and the promise is called at-least-once delivery. Once you understand it, a whole class of "how did this run twice?" mysteries stop being mysteries.

The three promises a queue can make

Every messaging system has to pick what it guarantees when things go wrong:

  • At most once: a message is delivered zero or one times. If something fails, it's lost. Fine for things like metrics where a missing data point doesn't matter.
  • At least once: a message is delivered one or more times. Nothing gets lost, but duplicates are possible. This is the default for most job queues: Laravel's queue drivers, SQS standard queues, RabbitMQ with acknowledgements and many others.
  • Exactly once: one delivery, always. This is what everyone wants. Hold that thought.

Where the duplicates come from

The core problem is that "do the work" and "tell the queue it's done" are two separate steps, and the world can end between them. A worker processes a message in roughly this order:

  1. Take the message, which becomes invisible to other workers for a while.
  2. Do the work: write to the database, call an API, send an email.
  3. Acknowledge (delete) the message.

If anything fails between step 2 and step 3, the work happened but the acknowledgement didn't. The message becomes visible again and someone runs it a second time. Common causes:

  • A worker crashes, gets killed by a deploy, or runs out of memory.
  • The network drops between the worker and the queue.
  • A job takes longer than the "invisible" window, so the queue assumes it died and hands it to another worker while the first one is still running.

That last one deserves its own section, because in Laravel it's one of the most common sources of duplicates.

The Laravel gotcha: retry_after vs timeout

In config/queue.php, each connection has a retry_after value: how many seconds the queue waits for a job before assuming it failed and releasing it again. Separately, the worker has a --timeout (or a job's $timeout): how long a job may run before the worker kills it.

If a job can run for 120 seconds but retry_after is 90, then at second 90 the queue hands the job to another worker. Now two copies run at the same time. The Laravel docs warn about exactly this: --timeout should always be several seconds shorter than retry_after, so a stuck job is killed before it's released.

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 190,
],

// worker: php artisan queue:work --timeout=180

Getting this right removes the "two at once" duplicates. It does nothing about the crash-before-acknowledge duplicates. For those, there's only one real answer.

Myth: "just use exactly-once delivery"

Exactly-once delivery across a network, to a consumer that has side effects in the outside world, isn't something a queue can promise on its own. The worker can always fail after doing the work but before reporting it. No protocol can make "sent the email" and "told the queue" happen as one atomic step, because they're different systems.

When you see "exactly-once" in a product's docs, read the small print. It usually means one of these:

  • Deduplication within a window. SQS FIFO queues, for example, drop duplicate messages sent with the same deduplication ID within a few minutes. That protects against duplicate sends, not against your worker running a message twice after a crash.
  • Exactly-once inside one system. Kafka's transactions can make reading and writing within Kafka atomic. The moment your consumer charges a card or sends an SMS, you're outside that boundary.

The honest formula: exactly-once effect = at-least-once delivery + an idempotent consumer. The queue makes sure nothing is lost. Your code makes sure duplicates don't matter.

Writing a consumer that shrugs off duplicates

An idempotent consumer can process the same message twice and leave the world exactly as if it ran once. The most reliable way is to give every message a stable ID and record which ones you've handled, in the same transaction as the work itself:

public function handle(): void
{
    DB::transaction(function () {
        $isNew = DB::table('processed_messages')->insertOrIgnore([
            'message_id' => $this->messageId, // unique column
            'processed_at' => now(),
        ]);

        if ($isNew === 0) {
            return; // seen it already, nothing to do
        }

        Payment::where('reference', $this->reference)
            ->update(['status' => 'confirmed']);
    });
}

The unique index on message_id does the heavy lifting. If two workers race, only one insert succeeds. And because the marker and the work commit together, a crash rolls back both, so the retry starts clean.

The ID has to come from the message, not be generated in the job. For webhooks, the provider's event ID is perfect. For your own jobs, create an ID when you dispatch and pass it in.

When the side effect isn't in your database

The transaction trick only covers your own database. An email, an SMS or a call to a payment gateway can't be rolled back. A few practical options:

  • Pass an idempotency key downstream. Many payment APIs accept one, so a retried call doesn't charge twice.
  • Record "sent" right after sending and check it before sending. There's still a tiny window, but it shrinks the problem from "every crash" to "a crash in the exact millisecond between two lines".
  • Make duplicates harmless. A second identical receipt is annoying. A second charge is a support ticket. Spend your effort where the duplicate actually hurts.

One more trap: Laravel's ShouldBeUnique stops the same job from being queued twice while one is pending. It's useful, but it doesn't stop a job from being run twice after a crash. It's not a replacement for idempotency.

The checklist

  • Assume every job and every webhook will occasionally run twice.
  • Set the worker timeout shorter than retry_after.
  • Give messages stable IDs and record processed IDs behind a unique index.
  • Do the work and the "processed" marker in one transaction where you can.
  • For external side effects, use idempotency keys or make duplicates harmless.

What's the most memorable duplicate you've seen from a queue: a double email, a double charge, or something stranger?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close