Menu
Dev.to #architecture·September 5, 2026

Achieving Idempotency in Distributed Systems to Prevent Duplicate Operations

This article explains why duplicate requests are an inherent challenge in distributed systems, especially due to network retries and timeouts. It details three primary architectural patterns for achieving idempotency: designing operations to be naturally idempotent, using idempotency keys for tracking and replaying requests, and leveraging conditional writes in the storage layer. The piece emphasizes that idempotency is a core aspect of reliable system design, not merely a bug fix.

Read original on Dev.to #architecture

The Inevitability of Duplicate Requests

In distributed systems, especially when network operations are involved, it's impossible for a caller to definitively know the state of a request after sending it, particularly if no response is received. A timeout could mean the request never arrived, it failed, or it succeeded but the response was lost. Given this ambiguity, reliable systems must implement retries, leading to the fundamental guarantee of "at-least-once" delivery. This means duplicates are not a bug to be fixed once, but a inherent challenge in the protocol that must be handled systematically.

ℹ️

The Core Idempotency Question

Question for Design Reviews: Instead of asking "could this get duplicated?" (the answer is always yes), ask "what happens when it does?" Every state-changing endpoint needs a robust, written answer to this question.

Three Architectural Approaches to Idempotency

The article outlines three primary architectural patterns for making operations idempotent, each with its own trade-offs and suitable use cases. Architects should prefer the simpler methods where possible, but be prepared to employ a mix of all three within a complex system.

1. Idempotency by Design (Natural Idempotence)

The most straightforward approach is to design operations such that repeating them has no additional effect. Examples include setting a value, deleting by ID (subsequent deletes are no-ops), or inserting with a unique key. A powerful pattern here is to transform "increments" into "facts" by recording immutable events rather than mutating running totals. This is analogous to an accountant's ledger, where a replayed event simply adds a duplicate entry that is collapsed by a unique constraint, and the true total is derived by summing unique facts.

sql
-- ❌ A duplicate literally doubles the money.
UPDATE accounts SET balance = balance + 50 WHERE id = 'acct_123';

-- ✅ A duplicate hits the unique constraint and no-ops.
INSERT INTO ledger_entries (id, account_id, amount) VALUES ('txn_abc', 'acct_123', 50) ON CONFLICT (id) DO NOTHING;
-- balance is now SUM(amount) over a set of de-duplicated facts.
💡

Split "Decide" from "Do"

The underlying principle is to split "decide" from "do". The decision becomes an idempotent fact written down (e.g., an insert with a unique key), and the "doing" becomes a separate worker draining those facts (e.g., sending a notification once per unique record).

2. Idempotency Keys

For operations with direct effects (like charges or orders), where natural idempotence isn't feasible, idempotency keys are the standard solution. The client generates a unique ID for each logical operation and sends it with every attempt. The server then uses this key to deduplicate requests. A robust implementation involves: atomically claiming the key, handling in-progress requests, storing the final outcome, and binding the key to the request payload to prevent key reuse with different data.

javascript
async function charge(req, res) {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });

  const fingerprint = sha256(canonicalize(req.body));

  // 1. Claim the key atomically. Whoever wins the INSERT does the work.
  const claimed = await db.query(
    `INSERT INTO idempotency_keys (key, fingerprint, status) VALUES ($1, $2, 'in_progress') ON CONFLICT (key) DO NOTHING RETURNING key`,
    [key, fingerprint]
  );

  if (claimed.rowCount === 0) {
    // The key already exists → this is a retry (or an abuse).
    const prior = await db.one(
      `SELECT status, fingerprint, response FROM idempotency_keys WHERE key = $1`,
      [key]
    );
    if (prior.fingerprint !== fingerprint) return res.status(422).json({ error: "Idempotency-Key reused with a different body" });
    if (prior.status === "in_progress") return res.status(409).json({ error: "Original request still in flight — retry shortly" });
    return res.status(prior.response.status).json(prior.response.body); // replay the original
  }

  // 2. We own the key. Do the real work exactly once.
  // ... (Perform the actual charge operation) ...

  // 3. Store the result and mark as complete.
  // ...
}

3. Conditional Writes at the Storage Layer

This method relies on the underlying storage system to enforce idempotency by using preconditions. For instance, updating a record only if its current version matches an expected value (optimistic locking) ensures that only the first successful update takes effect. This is less explicit than idempotency keys but can be effective for certain types of state changes when the storage layer supports it.

idempotencydistributed transactionsfault toleranceretriesat-least-once deliveryapi design patternsdata consistency

Comments

Loading comments...