This article is published in English.
Understanding Idempotency Keys in Node.js POST Endpoints
Explains why POST requests fail unpredictably on retry and how client-generated idempotency keys let Node.js APIs safely handle duplicate requests.
Idempotency is a word you'll find scattered across payment API documentation, usually accompanied by a dictionary definition that everyone skims past without really absorbing. What follows is an attempt to explain it through the questions developers actually ask once they run into it in production code, rather than the abstract version you'd get from a textbook.
What does "idempotent" really mean once you're writing code, not reading a glossary?
An operation qualifies as idempotent when firing it once produces the same end state as firing it five times in a row with the exact same input. Take PUT /users/8/name with a body of { "name": "Jane" }: whether you call it once or five times, the user's name ends up being "Jane" and nothing accumulates. Compare that to POST /orders with a payload meant to create a new order — call that five times and you'll likely end up with five separate orders, not one, because nothing about the operation prevents it from stacking.
Why does this become such a big deal specifically around POST requests?
POST is typically the verb responsible for creating things, and networks have a particular way of failing that makes this dangerous: a request can complete successfully on the server side while the client never learns about it, because the response itself gets lost somewhere on the way back. From the client's perspective, all it sees is a timeout. It has no way of knowing whether the order actually went through, so it does the only sensible thing it can: it retries.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
If the /orders endpoint isn't designed to tolerate that kind of retry, the customer ends up billed twice for a single purchase — and neither party is clearly at fault. The request truly did fail as far as the client could tell. It also truly succeeded as far as the server was concerned.
So what does it take to make a POST endpoint in Node actually idempotent?
The conventional fix is for the client to generate one unique key per logical operation, attach it as a header, and let the server use that key to recognize a retried request as the same request it already processed, rather than treating it as something new.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
It's the client's job to reuse that same key whenever it retries the same logical request — typically a UUID created once, right before the first attempt goes out. The server's job is simpler: recognize a key it has already seen, and return the stored result instead of redoing the work.
Who's supposed to generate the idempotency key, the client or the server?
It has to be the client, and this surprises a lot of people because their instinct says otherwise. If the server were the one minting the key, every retry would arrive with a fresh one, which would make the entire mechanism useless — the server would have no basis for telling a retry apart from a new request. The key needs to exist before the first attempt is even sent, precisely so the same value can be replayed if that attempt needs to be retried.
What if two identical requests land at literally the same moment, rather than one after the other?
This is the part that almost every first attempt at this pattern gets wrong. The straightforward "check, then insert" approach shown earlier has a race condition baked into it: two requests carrying the same key can both run their SELECT, both come up empty, and both proceed to create an order — completely undermining the purpose of the key in the first place.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Putting a unique constraint on the key column shifts the decision away from your application logic and onto the database itself: when two simultaneous requests collide, the database decides which one wins, and the other gets a clear, catchable error rather than quietly slipping through. An if statement in your route handler simply can't close this gap by itself — concurrency issues like this need to be resolved at whatever layer actually serializes access, and that layer is the database, not a conditional check in your code.
Does any of this matter for GET requests as well?
Not in the same way, and this trips people up regularly. GET is already supposed to be idempotent by design — it shouldn't alter anything, so retrying it freely is inherently safe without needing any special handling. The idempotency-key pattern exists specifically for operations that create or modify state, where a careless retry would double the effect. If a GET endpoint isn't already safe to call repeatedly, the real problem is that it's performing side effects it shouldn't be performing under GET semantics at all.
How long should an idempotency key remain valid?
Ideally long enough to cover realistic retry scenarios, but not so long that stored keys pile up indefinitely. Many payment platforms settle somewhere in the range of 24 hours up to a few days. A scheduled cleanup job can then purge expired entries:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Set the window too tight, and a retry that's delayed for a legitimate reason — a customer's phone drops signal for ten minutes in the middle of checkout, say — could fall outside the window and trigger a genuine duplicate. Leave it open forever, and the table just keeps growing without any real upside.
Is this pattern only worth caring about for payment systems?
Payments tend to be where people first learn this lesson, mostly because a duplicate charge is the sort of bug that produces an angry customer email within the hour. But the underlying issue — a client that can't distinguish between "my request failed" and "my request succeeded but I never heard back" — shows up anywhere there's a side effect: sending an email, firing a webhook, provisioning a new account, kicking off a background job. Any operation where a retry is plausible, and where running it twice would be worse than not running it at all, is a good candidate for this same approach.