DEV Community

Christian Vasquez
Christian Vasquez

Posted on

How I finally understood what idempotency is

The Intro

Idempotency. Eight letters, five syllables, and a word I pretended to understand in meetings for an embarrassingly long time.

It kept showing up in API docs, in Stripe's dashboard, in that one coworker's code review comments. I nodded. I did not know.

So today we are going to build the bug that makes idempotency matter, charge a fake customer twice, and then fix it. By the end, "idempotent" will be a word you can use in a sentence without sweating.

The What

Here is the dictionary version:

An operation is idempotent if performing it multiple times has the same effect as performing it once.

That's it. No, really, that's the whole concept. The hard part is noticing where it matters.

My favorite non-code example: the elevator button. Press it once, the elevator comes. Press it twelve times because you are late... the elevator still comes once. The button is idempotent. Impatient people everywhere rely on this.

Now compare that to a vending machine button. Press it twice and you get two sodas and one empty wallet. Not idempotent.

Most of your app is an elevator button. Some of it is a vending machine. The trouble starts when a vending machine gets pressed twice by accident.

The How

Let's build a tiny "pay for an order" endpoint with Express and TypeScript. One route, one fake payment provider, zero databases:

// src/server.ts
import express from 'express'

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

const orders = new Map<string, { total: number; paid: boolean }>()
orders.set('ord_1', { total: 50, paid: false })

async function chargeCard(amount: number) {
  console.log(`💳 charged $${amount}`)
  return `ch_${Math.random().toString(36).slice(2, 8)}`
}

app.post('/orders/:id/pay', async (req, res) => {
  const order = orders.get(req.params.id)
  if (!order) return res.status(404).json({ error: 'order not found' })

  const chargeId = await chargeCard(order.total)
  order.paid = true

  res.json({ chargeId })
})

app.listen(3000, () => console.log('listening on :3000'))
Enter fullscreen mode Exit fullscreen mode

Start it with npx tsx src/server.ts and pay for the order:

curl -X POST localhost:3000/orders/ord_1/pay
Enter fullscreen mode Exit fullscreen mode
💳 charged $50
Enter fullscreen mode Exit fullscreen mode

Lovely. Fifty bucks, one charge, everyone's happy. 🎉

Now let's be realistic about the internet. Your frontend sends the request, the server charges the card, and then... the Wi-Fi hiccups. The response never arrives. The frontend (or the HTTP client, or the user mashing the button) does what every client does when it sees a timeout: it retries.

curl -X POST localhost:3000/orders/ord_1/pay
curl -X POST localhost:3000/orders/ord_1/pay
Enter fullscreen mode Exit fullscreen mode
💳 charged $50
💳 charged $50
Enter fullscreen mode Exit fullscreen mode

Two charges. One order. One customer opening a support ticket with a lot of capital letters. 😬

Our endpoint is a vending machine. Every press costs money. And notice that the client did nothing wrong, retrying on a timeout is the correct thing to do. The endpoint just wasn't ready for it.

Step 1: look before you charge

The cheapest fix is to make the endpoint check its own state first:

app.post('/orders/:id/pay', async (req, res) => {
  const order = orders.get(req.params.id)
  if (!order) return res.status(404).json({ error: 'order not found' })

  if (order.paid) {
    return res.json({ chargeId: order.chargeId, alreadyPaid: true })
  }

  const chargeId = await chargeCard(order.total)
  order.paid = true
  order.chargeId = chargeId

  res.json({ chargeId })
})
Enter fullscreen mode Exit fullscreen mode

(Add chargeId?: string to the order type so TypeScript stops yelling.)

Run the double curl again:

💳 charged $50
Enter fullscreen mode Exit fullscreen mode

One charge. The second request got a polite "yep, already done" with the same chargeId as the first. Press the elevator button all you want now.

That's idempotency in one if. The operation "pay order 1" now has the same result whether you call it once or five hundred times.

Step 2: the sneaky gap

Before you ship, let's be honest about a hole. What if the two requests arrive at the same time? Both read paid === false, both charge, both set paid = true. Our if didn't help.

In a real app with a real database you close that gap by making the "check and mark" step atomic. In Postgres that's something like:

UPDATE orders SET paid = true WHERE id = $1 AND paid = false
Enter fullscreen mode Exit fullscreen mode

If that update touches one row, you won the race, go charge the card. If it touches zero rows, someone else got there first, return their result. Same idea as our if, just one that the database enforces so two requests can't both pass.

Step 3: idempotency keys (the grown-up version)

There's one more case the paid flag can't cover: what if the client legitimately wants to pay twice? Say, two different orders that happen to look identical, or a "buy again" button.

The industry answer is an idempotency key. The client generates a random ID per attempt and sends it in a header. The server remembers which keys it has seen:

const seen = new Map<string, unknown>()

app.post('/orders/:id/pay', async (req, res) => {
  const key = req.header('Idempotency-Key')
  if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' })

  if (seen.has(key)) {
    return res.json(seen.get(key))
  }

  const order = orders.get(req.params.id)
  if (!order) return res.status(404).json({ error: 'order not found' })

  const chargeId = await chargeCard(order.total)
  order.paid = true

  const response = { chargeId }
  seen.set(key, response)
  res.json(response)
})
Enter fullscreen mode Exit fullscreen mode
curl -X POST localhost:3000/orders/ord_1/pay -H "Idempotency-Key: abc-123"
curl -X POST localhost:3000/orders/ord_1/pay -H "Idempotency-Key: abc-123"
Enter fullscreen mode Exit fullscreen mode
💳 charged $50
Enter fullscreen mode Exit fullscreen mode

Same key, one charge, identical response both times. A retry is now safe by design: the client keeps the same key while retrying, and only generates a new one when it genuinely wants a new action.

This is exactly what Stripe, PayPal and friends ask you to send. Now you know why. 🧠

(In production, seen lives in your database or Redis with an expiry, not in a Map. But the shape is the same.)

Step 4: wait, we trust the client to pick the key?

Yep. And it has to be that way. Look at it from the server's side: two identical POST /orders/ord_1/pay requests show up. Is that a flaky network resending one payment, or a customer who genuinely wants to buy twice? The server has no idea. Only the thing that decided to pay knows if it's deciding again. So the client tags the decision, and retries carry the same tag.

What we don't do is trust the client blindly. The server adds a few guardrails:

  • Scope the key to the user. Look it up as (userId, key), not just key. Nobody can replay or collide with someone else's request.
  • Fingerprint the body. Store a hash of the request along with the key. Same key, different body? That's not a retry, that's someone being clever. Answer with a 422.
  • Expire keys. Keep them for a day or so, then forget them. A retry from last month is not a retry.
  • Lock while in flight. If a second request with the same key arrives while the first is still running, make it wait (or return a 409). That also closes the race from Step 2.

With those four, the client can only ever use a key to say "that same thing again, please". Which is exactly what we wanted.

The Why

Let's zoom out, because this is bigger than payments:

  • Retries are everywhere. HTTP clients retry. Message queues redeliver. Users double-click. Cron jobs overlap. If your operation isn't idempotent, every one of those is a bug waiting for a bad Wi-Fi day.
  • Idempotent code is restartable code. A script that imports 10,000 rows and crashes at row 7,000? If each row is an "insert or update", you just run it again. If it's "insert", you now have 7,000 duplicates and a long afternoon.
  • HTTP already has opinions. GET, PUT and DELETE are supposed to be idempotent. POST is not. That's why "create order" is a POST and "set order status" is a PUT. Delete something twice and the second call should say "fine, it's gone" rather than crash.
  • It's a design question, not a library. No package makes your code idempotent. You get it by asking, for every action: "what happens if this runs twice?" and making the answer "nothing new".

The one-sentence version to keep in your pocket: pressing it again should never make things worse.

The Goodbye

Next time someone drops "we should make that idempotent" in a review, you can nod and know. Even better, you can go find the endpoint in your app that charges, emails or creates something, run it twice, and see what happens.

Go press some elevator buttons. Happy coding 🤝

Top comments (0)