A customer taps “Pay” on a flaky hotel Wi-Fi. The request times out, the app retries, and your API charges the card twice. Support tickets, refunds, an angry tweet — all because of a concept nobody covers in the quick-start docs.
Most API tutorials stop at “make a request, parse the JSON.” The bugs that actually cost money live one level deeper: idempotency (what happens when the same request arrives twice), pagination (how to fetch a million rows without missing or duplicating any), and retries (how to handle failure without making it worse). Ignore any of the three and your integration works perfectly in testing — then quietly misbehaves in production.
This guide explains all three in plain English, shows the exact patterns real APIs like Stripe and GitHub use, and — most importantly — shows how they interact, because retries without idempotency is precisely how you double-charge someone. Let’s get to it.
1. Idempotency — the same request twice should act once
An operation is idempotent when running it multiple times has exactly the same effect as running it once. Pressing an elevator call button five times doesn’t summon five elevators — that button is idempotent. A “withdraw $50” button pressed five times is very much not.
Why it matters: networks fail in an evil way. When a request times out, you often can’t know whether the server processed it before the connection dropped. The response was lost — not necessarily the action. So every client eventually resends a request the server already handled. If that request creates a payment, an order, or an email blast, duplicates happen. It’s not a rare edge case; at any real traffic volume it’s a certainty.

Which HTTP methods are already idempotent?
HTTP defines this per method (see MDN’s glossary entry). GET, PUT, and DELETE are idempotent by contract: reading a resource twice changes nothing, setting a field to the same value twice ends in the same state, and deleting an already-deleted record is still deleted. The troublemaker is POST — “create something new” — where each call creates another something.
Idempotency keys: making POST safe
The standard fix is an idempotency key: the client generates a unique ID (usually a UUID) per logical action and sends it as a header. The server remembers which keys it has processed; if the same key arrives again, it skips the work and replays the saved response. Stripe’s API made this pattern famous:
POST /v1/charges HTTP/1.1
Host: api.stripe.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
amount=5000¤cy=usd&source=tok_visa
The rule of thumb: generate the key when the user intent happens (they clicked “Pay”), not when the request is sent — so every retry of that intent carries the same key. Best for: payments, order creation, provisioning, anything with side effects money cares about. Watch out for: reusing one key for two different actions — the server will happily return the first action’s cached response and silently drop the second.
2. Pagination — fetching big lists without losing rows
No sane API hands you 500,000 records in one response — it would be slow, memory-hungry, and one dropped packet from wasted. So lists come in pages, and you fetch them in a loop. The bite is in how the pages are defined, because the two common schemes fail very differently.
Offset pagination: simple until the data moves
GET /orders?limit=20&offset=40 ← "skip 40 rows, give me the next 20"
Offset pagination is the classic page=3 style: skip N rows, return the next batch. It’s trivial to implement and lets users jump straight to page 50. The trap: the data underneath keeps moving. If a new order is inserted while you’re between page 2 and page 3, every row shifts down — and the last row of page 2 shows up again at the top of page 3 (or worse, a row silently slips into a page you already fetched, and you never see it). On large tables it also gets slow, because the database really does count and discard all the skipped rows.
Cursor pagination: stable under change
GET /orders?limit=20&after=ord_18xKcD ← "give me 20 rows after this one"
Cursor pagination (also called keyset pagination) anchors each page to a specific row instead of a position: the response includes an opaque cursor — effectively “you stopped after order ord_18xKcD” — and the next request continues from there. Inserted or deleted rows can’t shift your window, and the database can seek directly to the anchor, so page 5,000 is as fast as page 1. That’s why GitHub, Slack, and Stripe all use cursors for their big collections. The trade-offs: you can’t jump to an arbitrary page, and cursors usually expire.

The practical rule: when you’re consuming an API, always loop until the next cursor/link is empty — never assume “one page was everything.” When you’re designing one, default to cursors for anything that grows, and treat offset as a convenience for small, mostly-static lists. And in both roles, expect duplicates anyway: if a sync job can be interrupted and resumed, de-duplicate by ID on your side.
3. Retries — handling failure without causing more of it
Requests fail. The naive response — retry immediately, in a tight loop — is how you turn one failure into an outage. If a server hiccups for two seconds and a thousand clients all hammer it with instant retries, the recovering server gets buried by its own traffic. Engineers call this a retry storm, and it’s self-inflicted.
Exponential backoff + jitter
The standard pattern is exponential backoff: wait 1 second before the first retry, then 2, then 4, then 8 — doubling each time so a struggling server gets room to breathe. Then add jitter (a random offset on each wait): without it, every client that failed at the same moment retries at the same moment too, arriving in synchronized waves. Randomness spreads them out.

Know what to retry — and what never to
- Retry: network timeouts,
429 Too Many Requests(you hit a rate limit), and5xxserver errors like502/503— these are temporary by nature. - Respect
Retry-After: when a429or503response includes this header, it tells you exactly how long to wait. Honor it instead of guessing. - Never retry:
400 Bad Request,401/403auth failures,404,422validation errors. The request itself is wrong — sending it again just burns your rate limit. (If you’re hitting auth errors, fix the credentials: see our guide to API authentication.) - Cap it: set a maximum attempt count (3–5 is typical) and a total time budget. Infinite retries are an outage generator, not resilience.
Where they bite together: retries need idempotency
Here’s the part most tutorials miss: these three concepts are one system, not three chapters. A retry policy on a non-idempotent endpoint is a duplicate-generator. The timeout that triggers your retry is exactly the case where you can’t know if the first attempt landed — so the safe sequence is: attach an idempotency key first, then retry with backoff, reusing the same key on every attempt. Stripe’s official client libraries do precisely this out of the box.
Pagination joins the party in sync jobs: a crawler that retries a failed page-fetch under offset pagination can land on shifted rows, importing duplicates or skipping records. Cursors plus ID-based de-duplication make the whole pipeline safe to interrupt and resume — which, at production scale, it will be.
| Concept | The bug it prevents | The pattern | Bites hardest in |
|---|---|---|---|
| Idempotency | Duplicate charges, orders, emails | Idempotency key per user intent | Payments & anything with side effects |
| Pagination | Missing or repeated rows | Cursors + loop until no next page | Data syncs on changing datasets |
| Retries | Retry storms, burned rate limits | Exponential backoff + jitter + caps | High-traffic clients during outages |
The pre-launch checklist
Before your integration ships, walk this list — it takes ten minutes and saves real money:
- Every request that creates something carries an idempotency key (generated at user intent, reused across retries).
- Every list fetch loops until the API says there’s no next page — and de-duplicates by ID.
- Retries use exponential backoff with jitter, honor
Retry-After, and stop after a capped number of attempts. - Only timeouts,
429, and5xxare retried —4xxclient errors fail fast to your error handling. - You’ve tested the ugly path: kill the network mid-request and confirm no duplicates appear.
Frequently asked questions
What does idempotent mean in an API?
An idempotent API operation produces the same result whether you call it once or many times. GET, PUT, and DELETE are idempotent by HTTP contract; POST is not, which is why payment APIs add idempotency keys to make repeated POST requests safe.
What is an idempotency key and when should I use one?
It is a unique ID (usually a UUID) the client sends with a request so the server can recognize and ignore duplicates of the same action. Use one on every request that creates something with side effects — payments, orders, signups — and reuse the same key across retries of that action.
Which is better: offset or cursor pagination?
Cursor pagination is better for large or frequently-changing datasets — it stays consistent while rows are added or removed and stays fast at any depth. Offset pagination is fine for small, mostly-static lists and when users need to jump to a specific page number.
Why do APIs use exponential backoff instead of instant retries?
Instant retries from many clients at once can bury a recovering server in traffic — a retry storm. Exponential backoff doubles the wait after each failure (1s, 2s, 4s, 8s), and adding random jitter stops clients from retrying in synchronized waves.
Which HTTP errors should I retry?
Retry timeouts, 429 rate-limit responses, and 5xx server errors like 502 or 503 — they are temporary. Never retry 4xx client errors such as 400, 401, 403, or 422: the request itself is wrong, and resending it just burns your rate limit. Always honor a Retry-After header when the API sends one.
Can I safely retry a POST request?
Only if the endpoint supports idempotency — otherwise a retry can create a duplicate charge or order, because a timeout does not tell you whether the first attempt succeeded. Attach an idempotency key and reuse it on every retry; then the server processes the action once no matter how many attempts arrive.
The bottom line
Idempotency, pagination, and retries are the difference between an integration that demos well and one that survives production. The mental model is simple: assume every request can arrive twice, every list can change mid-fetch, and every call can fail — then design so that none of those events corrupts your data. Idempotency keys make duplicates harmless, cursors make big fetches stable, and backoff with jitter makes failure recoverable instead of contagious.
Want to round out your API fundamentals? Read our guides on REST vs GraphQL vs SOAP and API authentication (keys, OAuth, JWT & bearer), or browse all our API guides.





