Implementing Express Idempotency: Preventing Duplicate API Requests
Stop duplicate API requests in their tracks. Learn how to implement robust express idempotency using Redis to lock async operations and ensure data integrity.
We’ve all dealt with the "double-submit" nightmare: a user clicks a button twice, or a flaky client sends the same POST request twice in under 500ms. If your endpoint triggers an expensive side effect—like charging a card or generating a report—that second request can lead to inconsistent state or, worse, duplicate billing.
When I first tackled this, I tried simple in-memory flag checks. It worked fine until we scaled to multiple instances, where the state was obviously trapped in a single process. You need a distributed solution, which is where a node.js redis cache becomes the backbone of your strategy.
Why Express Idempotency Matters
At its core, express idempotency is about ensuring that making the same request multiple times has the same effect as making it once. Without it, you’re just hoping that your frontend team is aggressive enough with their loading states.
We don't rely on hope in production. We build constraints.
The Implementation Strategy
The most reliable way to handle this is by using an Idempotency-Key header sent by the client. If the server sees a key it has processed recently, it returns the cached result of the previous operation instead of re-running the logic.
Here is a simple middleware I use to gate incoming requests:
JAVASCRIPTconst redis = require(CE9178">'redis'); const client = redis.createClient(); const idempotencyMiddleware = async (req, res, next) => { const key = req.headers[CE9178">'idempotency-key']; if (!key) return res.status(400).send(CE9178">'Missing Idempotency-Key'); const lock = await client.get(CE9178">`lock:${key}`); if (lock) return res.status(409).send(CE9178">'Request already in progress'); // Set a lock with a 30-second TTL await client.set(CE9178">`lock:${key}`, CE9178">'true', { EX: 30 }); next(); };
Preventing Duplicate API Requests with Atomic Locks
The code above is a start, but it has a race condition. If two requests hit the server at the exact same time, both might pass the client.get check before either sets the lock.
To fix this, we use the NX (Set if Not Exists) option in Redis. This makes the operation atomic, ensuring that even under heavy concurrent load, only one request wins the race.
| Approach | Reliability | Complexity | Scalability |
|---|---|---|---|
| In-Memory Set | Low | Low | Poor |
Redis GET + SET | Medium | Medium | Good |
Redis SET NX | High | Moderate | Excellent |
Using the SET NX approach is the industry standard for preventing duplicate api requests. It’s efficient and keeps your logic clean.
Handling Async Request Handling in Production
When you’re deep into async request handling, you have to be careful about what happens when the operation fails. If your process crashes after setting the Redis lock but before finishing the DB write, you’ve effectively "bricked" that idempotency key for the duration of your TTL.
I always wrap my core logic in a try...finally block to ensure the lock is released if an error occurs:
JAVASCRIPTapp.post(CE9178">'/charge', idempotencyMiddleware, async (req, res) => { const key = req.headers[CE9178">'idempotency-key']; try { const result = await processPayment(req.body); res.json(result); } catch (err) { await client.del(CE9178">`lock:${key}`); // Clean up on failure res.status(500).send(CE9178">'Payment failed'); } });
For more complex scenarios, you might want to look into how to prevent cache stampedes or how the Redis idempotency pattern works in larger distributed architectures. If you're managing multiple async tasks, ensure you're using Promise.allSettled to keep your API robust.
What I’d Do Differently Next Time
Looking back, I initially tried to store the response in Redis as well, so I could return the exact same JSON for the second request. While that's the "correct" RESTful way to do it, it significantly increases the complexity of your Redis schema.
For now, I prefer simple locking. It’s easier to debug and covers 99% of the accidental double-submit cases I see in the wild. Just keep an eye on your Redis TTLs; if your operations take longer than expected, you'll end up with frustrated users getting 409 errors because the lock hasn't expired yet.
FAQ
How do I handle clients that don't send an Idempotency-Key? You have two choices: reject the request with a 400 status code, or generate a hash of the request body and user ID to use as a fallback key. I prefer rejecting them; it forces the client to be explicit about their intent.
Is 30 seconds enough for a TTL? It depends entirely on your latency. If your external payment gateway takes 28 seconds to respond, you're cutting it close. I usually set it to something safe like 60 seconds.
What happens if Redis goes down? Your API will throw an error when it tries to set the lock. You should wrap your Redis calls in a try-catch and decide if you want to "fail open" (proceed without idempotency) or "fail closed" (reject the request). I usually fail closed to protect data integrity.
