DEV Community

Cover image for Why Your Production API Needs Idempotency Keys (And How to Build an Engine in Node.js & Redis)
Mindinu Ariyawansha
Mindinu Ariyawansha

Posted on

Why Your Production API Needs Idempotency Keys (And How to Build an Engine in Node.js & Redis)

In a perfect network, every HTTP request arrives exactly once. In the real world, mobile clients drop connection mid-flight, timeouts hit load balancers, and frontend retry logic triggers duplicate operations.

If a user clicks "Pay Now" on a $100 checkout, their connection drops, and their app automatically retries the request 3 seconds later, what happens?

Without an Idempotency Engine, your API risks processing two charges for a single intent.

While GET, PUT, and DELETE methods are naturally idempotent by HTTP spec, POST endpoints (creating charges, generating invoices, triggering AI workflows) are inherently non-idempotent.

Here is how production systems guarantee exact-once execution semantics using Idempotency Keys.


How an Idempotency Engine Works

An idempotency key is a unique, client-generated identifier (usually a v4 UUID) sent in the HTTP header:

Idempotency-Key: 7b9e1d84-2a3c-4e89-9102-1a4f52e39a01

When the server receives a request with an idempotency key, it enters a state machine execution loop:

                  +-----------------------------------+
                  |   Incoming Request + Header Key   |
                  +-----------------------------------+
                                    |
                                    v
                       [ Check Redis Cache for Key ]
                                    |
                     +--------------+--------------+
                     |                             |
             (Key Exists?)                   (Key Missing?)
                     |                             |
          +----------+----------+                  v
          |                     |       [ Set Key State: "PROCESSING" ]
   (State: COMPLETE)   (State: PROCESSING)         |
          |                     |                  v
          v                     v       [ Execute Business Logic ]
   [ Return Saved ]      [ Return 409 ]            |
   [ HTTP Response ]     [ Conflict ]              v
                                        [ Save Response + State: "COMPLETE" ]
Enter fullscreen mode Exit fullscreen mode
  1. First Request: The server checks Redis for the key. Missing. It stores key: "PROCESSING" with a short TTL (e.g., 30 seconds) and executes the logic.
  2. Concurrent Duplicate: If a second request arrives with the same key while the first is still processing, the server immediately rejects it with 409 Conflict or 429 Too Many Requests.
  3. Completed Duplicate: Once the first request succeeds, the server caches the HTTP status code and response body in Redis under that key. Subsequent retries return the cached response instantly without re-executing any logic.

Production Middleware Implementation (Express + Redis)

Here is a clean, dependency-light middleware implementation in Node.js using Redis:

import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const IDEMPOTENCY_TTL = 86400; // 24 Hours in seconds

export const idempotencyMiddleware = async (
  req: Request, 
  res: Response, 
  next: NextFunction
) => {
  const key = req.header('Idempotency-Key');

  // Skip if client didn't supply a key (or enforce it for critical routes)
  if (!key) return next();

  const redisKey = `idempotency:${key}`;

  try {
    // Atomically set key if it doesn't exist (NX) with a lock timeout
    const acquired = await redis.set(redisKey, JSON.stringify({ state: 'PROCESSING' }), 'EX', 30, 'NX');

    if (!acquired) {
      const cachedData = await redis.get(redisKey);

      if (cachedData) {
        const parsed = JSON.parse(cachedData);
        if (parsed.state === 'PROCESSING') {
          return res.status(409).json({ error: 'Concurrent request in progress. Please wait.' });
        }
        // Return previously cached execution response
        return res.status(parsed.statusCode).json(parsed.body);
      }
    }

    // Intercept res.json to capture response payload before sending to client
    const originalJson = res.json.bind(res);
    res.json = (body: any) => {
      // Save execution result to Redis for future retries
      redis.set(
        redisKey, 
        JSON.stringify({ state: 'COMPLETE', statusCode: res.statusCode, body }), 
        'EX', 
        IDEMPOTENCY_TTL
      );
      return originalJson(body);
    };

    next();
  } catch (err) {
    next(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

Crucial Edge Cases to Consider

1. Request Payload Validation

What if a malicious actor sends the same Idempotency-Key with a completely different JSON payload?

  • Solution: Hash the request body alongside the key (SHA256(Key + Payload)). If the key matches but the payload hash differs, return 400 Bad Request.

2. Handling Hard Failures (5xx Server Errors)

If your database or downstream service crashes while processing a request, do not save a 500 error as a permanent idempotent response.

  • Delete the Redis key if the request throws an unhandled exception so the client can safely retry after your system recovers.

3. Distributed Lock Leaks

Always set an expiration TTL (e.g., 30 seconds) on the initial PROCESSING lock. If your API worker dies mid-execution, the key will naturally expire rather than permanently locking out the user from retrying.


Summary

Handling retries cleanly is the difference between a brittle prototype and enterprise-ready API infrastructure. By pushing idempotency tracking to a fast Redis layer, you protect your database from duplicate writes and give your client applications a bulletproof retry strategy.

Top comments (1)

Collapse
 
stratcorealpha profile image
Arnold Holm

One failure case worth adding: let the first handler run longer than the 30-second lock, then send the same key again. The second request can acquire the expired key while the first still executes. The first handler can also overwrite the newer request's state when it finishes.

I would narrow the exact-once claim here. A Redis lock and response cache alone do not cover a side effect that succeeds just before the worker crashes. Retrying after that crash needs durable reconciliation or idempotency at the side-effect boundary.

For the example's tests, I would include lock expiry during execution, a crash after the side effect but before the response is stored, and the same key used by two authenticated callers. Those cases make the guarantee's scope much clearer.