DEV Community

Tech Forge
Tech Forge

Posted on

Idempotency for Reliable APIs

Why idempotency matters

When you build an API, retries are inevitable. Clients lose connections, timeouts happen, and users double-click buttons. Without idempotency, a retried request can create duplicate orders, charge a credit card twice, or send two emails. Idempotency ensures that the same request, applied multiple times, has the same effect as applying it once.

What is idempotency?

An operation is idempotent if performing it once or multiple times produces the same result. In HTTP, GET, PUT, DELETE are naturally idempotent. POST is not, because it creates new resources. But we can make POST idempotent by using an idempotency key.

The idempotency key pattern

The client generates a unique key (usually a UUID) and sends it in a header, like Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000. The server stores this key along with the response for a certain period. If the same key arrives again, the server returns the stored response instead of processing the request again.

Implementation example

Let's build a simple idempotent endpoint in Node.js with Express. We'll use an in-memory store for simplicity, but in production you'd use Redis or a database.

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

// In-memory store: key -> { response, expiresAt }
const idempotencyStore = new Map();
const TTL_MS = 60 * 60 * 1000; // 1 hour

app.post('/orders', async (req, res) => {
  const key = req.headers['idempotency-key'];
  if (!key) {
    return res.status(400).json({ error: 'Missing Idempotency-Key header' });
  }

  // Check existing
  const existing = idempotencyStore.get(key);
  if (existing && existing.expiresAt > Date.now()) {
    return res.status(existing.status).json(existing.body);
  }

  // Process the order (simulate work)
  const order = {
    id: crypto.randomUUID(),
    product: req.body.product,
    quantity: req.body.quantity,
  };

  // Store the response
  const response = { status: 201, body: order };
  idempotencyStore.set(key, { ...response, expiresAt: Date.now() + TTL_MS });

  // Clean up expired entries periodically
  setTimeout(() => idempotencyStore.delete(key), TTL_MS);

  res.status(201).json(order);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

This works, but there's a race condition: if two identical requests arrive simultaneously, both might pass the existing check and process twice. To prevent that, use a lock or atomic operation.

Atomic handling with a database

In a real system, you'd store the idempotency key in a database with a unique constraint. For example, using PostgreSQL:

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  response JSONB NOT NULL,
  status_code INT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Then in your service, try to insert the key. If it fails due to duplicate, fetch the existing response. This gives you atomicity.

const { Pool } = require('pg');
const pool = new Pool();

app.post('/orders', async (req, res) => {
  const key = req.headers['idempotency-key'];
  if (!key) return res.status(400).json({ error: 'Missing key' });

  const client = await pool.connect();
  try {
    // Try to insert a placeholder; on conflict, we know it's a retry
    const insertResult = await client.query(
      `INSERT INTO idempotency_keys (key, response, status_code)
       VALUES ($1, '{}', 0)
       ON CONFLICT (key) DO NOTHING
       RETURNING key`,
      [key]
    );

    if (insertResult.rowCount === 0) {
      // Key exists, fetch stored response
      const existing = await client.query(
        'SELECT response, status_code FROM idempotency_keys WHERE key = $1',
        [key]
      );
      return res.status(existing.rows[0].status_code).json(existing.rows[0].response);
    }

    // Process the order
    const order = { id: crypto.randomUUID(), product: req.body.product };

    // Update the row with the actual response
    await client.query(
      'UPDATE idempotency_keys SET response = $1, status_code = $2 WHERE key = $3',
      [order, 201, key]
    );

    res.status(201).json(order);
  } finally {
    client.release();
  }
});
Enter fullscreen mode Exit fullscreen mode

Client-side pattern

Clients should generate a new key for each logical operation, and reuse it when retrying the same operation. For example, if a user submits a form and the request fails, the client should retry with the same key.

const key = crypto.randomUUID();

async function createOrder(payload) {
  try {
    const res = await fetch('/orders', {
      method: 'POST',
      headers: { 'Idempotency-Key': key },
      body: JSON.stringify(payload),
    });
    return res.json();
  } catch (e) {
    // Retry with same key
    return createOrder(payload);
  }
}
Enter fullscreen mode Exit fullscreen mode

Common pitfalls

  • Key reuse across different operations: A key should be unique per operation. Don't reuse the same key for different payloads.
  • Expiry too short: If you expire keys too quickly, a delayed retry might slip through. Use a reasonable TTL (e.g., 24 hours).
  • Not returning the stored response: When a duplicate key arrives, you must return the exact same response, including status code and body.
  • Ignoring idempotency for non-idempotent endpoints: Not all endpoints need it, but any that create resources or trigger side effects should have it.

Conclusion

Idempotency is a simple concept with a big impact on reliability. By adding an idempotency key to your POST endpoints, you make retries safe and give clients confidence. Start with a small implementation, test it under concurrency, and you'll prevent a whole class of bugs.

Remember: the goal is that the user sees one result, no matter how many times the request is sent.

Top comments (0)