DEV Community

Tech Forge
Tech Forge

Posted on

Idempotency for Reliable APIs: A Practical Guide

Why Idempotency Matters

When building APIs, network failures are inevitable. A client sends a request, the server processes it, but the response is lost. The client retries, and suddenly you have duplicate orders, double charges, or duplicated database records. Idempotency solves this by ensuring that repeating the same request has the same effect as making it once.

An operation is idempotent if making multiple identical requests produces the same result as a single request. GET, PUT, DELETE are naturally idempotent in REST, but POST is not. That's where you need to add explicit support.

The Idempotency Key Pattern

The standard approach is to have the client generate a unique idempotency key for each operation and send it in a header. The server stores the key and the response for the first request. If the key is seen again, the server returns the stored response without re-executing the operation.

Here's a simple implementation in Node.js with Express and Redis:

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

const app = express();
const client = redis.createClient();

app.use(express.json());

app.post('/orders', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Missing idempotency-key header' });
  }

  const cacheKey = `idem:${idempotencyKey}`;

  // Check if we've seen this key before
  const cached = await client.get(cacheKey);
  if (cached) {
    return res.status(200).json(JSON.parse(cached));
  }

  // Process the order (e.g., create in DB)
  const order = {
    id: crypto.randomUUID(),
    ...req.body
  };

  // Store the response with a TTL (e.g., 24 hours)
  await client.set(cacheKey, JSON.stringify(order), 'EX', 86400);

  res.status(201).json(order);
});
Enter fullscreen mode Exit fullscreen mode

Key points:

  • The client must generate a unique key for each logical operation. UUIDs are a good choice.
  • Store the response, not just a flag, so retries can return the exact same result.
  • Use a TTL to avoid unbounded storage.

Handling Concurrent Requests

Race conditions can occur if two identical requests arrive simultaneously. Both might check the cache, find nothing, and process the operation twice. Use atomic operations to prevent this.

With Redis, you can use SET NX (set if not exists):

const setIfNotExists = await client.set(cacheKey, 'processing', 'NX', 'EX', 86400);

if (!setIfNotExists) {
  // Key exists, maybe still processing or done
  const cached = await client.get(cacheKey);
  if (cached === 'processing') {
    // Wait and retry, or return 409 Conflict
    return res.status(409).json({ error: 'Request already in progress' });
  }
  return res.status(200).json(JSON.parse(cached));
}

// Process the order...
const order = { id: crypto.randomUUID(), ...req.body };
await client.set(cacheKey, JSON.stringify(order), 'EX', 86400);
res.status(201).json(order);
Enter fullscreen mode Exit fullscreen mode

If the first request is still processing, returning 409 tells the client to retry after a short delay. Many clients handle this well.

What Key Should the Client Use?

Never reuse the same key for different operations. For example, if you're creating a payment, generate a new UUID for each payment attempt. If the client retries the same payment, it sends the same key. If it's a genuinely new payment, it uses a new key.

Some APIs derive keys from a unique business identifier, like an order number. That works if you're sure that order number will never be used again for a different operation.

Idempotency for Non-POST Methods

While PUT and DELETE are idempotent by nature, you still need to handle retries properly. For example, if a DELETE request times out but the server actually deleted the resource, a retry should not return an error. Design your endpoints to treat a missing resource as success for DELETE.

Practical Tips

  • Always require the Idempotency-Key header for state-changing operations. Return 400 if missing.
  • Validate key format (e.g., max length 255, UUID).
  • Include the idempotency key in your API documentation and client SDKs.
  • Log idempotency key usage for debugging.
  • For multi-step processes, consider using idempotency keys at each step.

Testing Idempotency

Write tests that simulate retries. Send the same request twice and assert that only one resource is created and both responses are identical. Also test concurrent requests to ensure no race conditions occur.

Conclusion

Idempotency is not optional for production APIs that handle money, orders, or any state changes. It's a simple pattern that saves your users from painful error handling and your database from duplicates. Start with the idempotency key header, store responses, handle concurrency atomically, and you'll have a much more reliable API.

Remember: the goal is that a retry should never cause unintended side effects. Your clients will thank you.

Top comments (0)