Blog / Distributed Systems

Exactly-Once Delivery: Myth vs Reality

Why the holy grail of distributed systems is mathematically impossible, and what you should actually do about duplicate webhooks.

November 27, 2025 · 12 min read

"Does your system support exactly-once delivery?"

If you've worked with webhooks, message queues, or any distributed system, you've probably asked this question. And if someone answered "yes" without hesitation, they either misunderstood the question or are selling you something.

Here's the uncomfortable truth: exactly-once delivery is impossible in distributed systems. Not "really hard." Not "we haven't figured it out yet." Mathematically, provably impossible.

But don't despair. Understanding why it's impossible leads to building systems that are actually reliable. Let's dig in.

The Two Generals Problem

The impossibility of exactly-once delivery comes from a fundamental result in distributed systems theory called the Two Generals Problem.

Imagine two armies on opposite sides of a valley, needing to coordinate an attack. They can only communicate by sending messengers through the valley—but messengers might be captured. How can both generals be certain they'll attack at the same time?

This regress is infinite. There's no number of messages that can guarantee both parties know the other received their message. This was proven impossible in 1975, and it applies directly to networks.

"In a network, you can never be certain that your message was received, and the recipient can never be certain you know they received it."

What This Means for Webhooks

When Stripe sends you a webhook, here's what can happen:

Scenario 1: Happy path

  1. Stripe sends webhook
  2. Your server receives it
  3. Your server processes it
  4. Your server returns 200 OK
  5. Stripe receives the 200

✓ Everyone's happy

Scenario 2: The duplicate

  1. Stripe sends webhook
  2. Your server receives it
  3. Your server processes it
  4. Your server returns 200 OK
  5. Network hiccup: Stripe never receives the 200
  6. Stripe thinks it failed, sends the webhook again
  7. Your server processes it again

✗ Customer charged twice

This isn't a bug in Stripe's system. It's not a bug in your system. It's a fundamental property of networks. The sender cannot distinguish between "message lost" and "acknowledgment lost."

The Three Delivery Guarantees

Every messaging system offers one of three guarantees:

Guarantee Meaning Risk
At-most-once Message delivered 0 or 1 times Messages can be lost
At-least-once Message delivered 1 or more times Messages can be duplicated
Exactly-once Message delivered exactly 1 time Impossible*

*When people say "exactly-once," they usually mean at-least-once delivery + idempotent processing. The delivery still happens multiple times, but the effect only happens once.

This distinction matters. You can't prevent duplicates from arriving. But you can prevent duplicates from causing problems.

The Real Solution: Idempotency

An operation is idempotent if performing it multiple times has the same effect as performing it once. This is the key to handling webhook duplicates safely.

Here's a simple idempotent webhook handler:

// ❌ NOT idempotent - will charge twice on duplicate
app.post('/webhook', async (req, res) => {
  const { customerId, amount } = req.body;
  await chargeCustomer(customerId, amount);
  res.sendStatus(200);
});

// ✅ Idempotent - safe to receive duplicates
app.post('/webhook', async (req, res) => {
  const { eventId, customerId, amount } = req.body;

  // Check if we've already processed this event
  const existing = await db.get(
    'SELECT * FROM processed_events WHERE event_id = ?',
    eventId
  );

  if (existing) {
    // Already processed, just acknowledge
    return res.sendStatus(200);
  }

  // Process the event
  await chargeCustomer(customerId, amount);

  // Record that we processed it
  await db.run(
    'INSERT INTO processed_events (event_id, processed_at) VALUES (?, ?)',
    eventId, Date.now()
  );

  res.sendStatus(200);
});

Idempotency Keys: Your Best Friend

Most webhook providers include a unique identifier with each event:

Use this ID as your idempotency key. Store it when you successfully process an event, and check for it before processing any new event.

⚠️ Important: Use a unique constraint

Don't just check-then-insert. Use a database unique constraint on the event ID column. This prevents race conditions where two duplicate webhooks arrive simultaneously.

-- Create table with unique constraint
CREATE TABLE processed_events (
  event_id TEXT PRIMARY KEY,  -- Prevents duplicates at DB level
  processed_at INTEGER NOT NULL,
  payload TEXT
);

-- Insert with conflict handling
INSERT INTO processed_events (event_id, processed_at, payload)
VALUES (?, ?, ?)
ON CONFLICT (event_id) DO NOTHING;  -- Silently ignore duplicates

Making Operations Naturally Idempotent

Sometimes you can redesign your operations to be inherently idempotent:

Instead of incrementing...

UPDATE users SET credits = credits + 100 WHERE id = ?

Set to absolute value:

UPDATE users SET credits = 100 WHERE id = ? AND credits < 100

Instead of creating...

INSERT INTO orders (customer_id, amount) VALUES (?, ?)

Use upsert with external ID:

INSERT INTO orders (external_id, customer_id, amount) VALUES (?, ?, ?) ON CONFLICT DO NOTHING

Cleaning Up Old Idempotency Keys

You don't need to store idempotency keys forever. Most webhook providers retry within a window of hours or days. A retention period of 7-30 days is typically sufficient.

// Daily cleanup job
async function cleanupOldIdempotencyKeys() {
  const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);

  await db.run(
    'DELETE FROM processed_events WHERE processed_at < ?',
    thirtyDaysAgo
  );
}

How EventDock Handles This

At EventDock, we provide idempotency as a built-in feature:

This means you can write simple webhook handlers without complex idempotency logic—we handle it at the infrastructure layer.

Summary

  • 1. Exactly-once delivery is impossible due to fundamental network properties
  • 2. At-least-once + idempotency gives you "effectively exactly-once" processing
  • 3. Use provider event IDs as idempotency keys
  • 4. Use database constraints to prevent race conditions
  • 5. Design operations to be naturally idempotent when possible

The next time someone asks if you support "exactly-once delivery," you'll know the right answer: "We do at-least-once with idempotent processing, which is the only way to achieve reliable delivery in the real world."

Stop worrying about duplicate webhooks

EventDock handles deduplication, retries, and delivery tracking so you can focus on your application logic.

Start Free

Related Articles