Dev.to AI 🤖 Ai 👁 0 📖 7 min read

Your API Timed Out. Did the Order Still Go Through?

A customer clicks Place Order. The spinner turns… and turns… then: Request timed out. So they click again. Now you have two orders, two charges, and one angry support ticket. Here's the uncomfortable truth: a timeout

A customer clicks Place Order.

The spinner turns… and turns… then: Request timed out.

So they click again.

Now you have two orders, two charges, and one angry support ticket.

Here's the uncomfortable truth: a timeout tells you nothing about whether the server did the work.

  • A timeout means "no answer," not "no order."
  • Give every operation an idempotency key: created once by the client, reused on every retry.
  • Let a database unique constraint decide which attempt wins, not an if check.
  • Save the order and its response in the same write.
  • A retry with a known key gets the original response back instead of creating a new order.

What actually happened

Client                     Server                    Database
  │                           │                          │
  │── POST /orders ──────────►│                          │
  │                           │── INSERT order ─────────►│  ✅ committed
  │      ✖ connection drops ◄─│── 201 Created            │
  │                           │                          │
  │  "Request timed out"      │                          │
  │                           │                          │
  │── POST /orders (retry) ──►│                          │
  │                           │── INSERT order ─────────►│  ✅ committed AGAIN 😬

The server did its job. Only the response got lost.

From the client's side, a timeout could mean any of three things:

  1. The request never reached the server.
  2. The request reached the server and failed.
  3. The request succeeded, but the response was lost.

The client can't tell which one happened. So the backend has to be built so that retrying is always safe.

The fix in one sentence

Give the operation an identity that stays the same across retries.

Think of a paper check. If check #1042 shows up twice, the bank doesn't pay it twice. The check number identifies the intent to pay, no matter how many times it's presented.

An idempotency key is the check number for your API request.

Step 1: The client creates the key once

// Create the key ONCE, when the customer decides to place this order.
const pendingOrder = {
  key: crypto.randomUUID(),
  body: { sku: "KEYBOARD-01", quantity: 1 },
};

async function submitOrder() {
  return fetch("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": pendingOrder.key, // same key on every retry
    },
    body: JSON.stringify(pendingOrder.body),
  });
}

⚠️ The most common bug: generating the key inside submitOrder(). Then every retry gets a new key and looks like a brand-new order, which defeats the whole point.

If retries need to survive a page reload or app restart, persist the pending operation and its key somewhere durable. A variable in memory disappears when the page does.

Why not just detect duplicate request bodies?

Because identical requests don't always mean identical intent. A customer might genuinely want to order the same keyboard twice. The Amazon Builders' Library makes this point: the caller has to say which attempts belong together.

The API contract

Request Behavior
New key Create the order
Same customer, same key, same input Return the original response
Same customer, same key, different input 409 Conflict
Different customer, same key Treat as a separate operation

🔐 Authenticate and authorize every attempt, including replays. An idempotency key is not a credential.

Step 2: Save the order and the replay record together

This looks reasonable, but it's broken:

await createOrder(input);
await saveIdempotencyResult(key, response); // 💥 crash here = no record

If the process crashes between those two lines, the order exists but nothing remembers the key. The next retry creates a second order.

The order and the record of the key must commit together, or not at all.

The simplest way is to keep them in the same row:

CREATE TABLE orders (
  id                UUID PRIMARY KEY,
  customer_id       UUID NOT NULL,
  idempotency_key   TEXT NOT NULL,
  request_payload   JSONB NOT NULL,
  creation_response JSONB NOT NULL,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),

  UNIQUE (customer_id, idempotency_key)
);

That UNIQUE constraint is what makes everything else work.

(This table serves only the create-order operation. If you build a shared idempotency table, add an operation name to the unique key.)

Step 3: Let the database pick the winner

The obvious approach fails under concurrency:

Request A: SELECT key → not found
Request B: SELECT key → not found
Request A: INSERT order ✅
Request B: INSERT order ✅   ← duplicate

Checking first and then inserting leaves a gap that two requests can both slip through. Instead, just try the insert and let PostgreSQL's unique constraint decide which one wins.

import { randomUUID } from "node:crypto";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

type OrderInput = { sku: string; quantity: number };
type CreationResponse = { orderId: string; status: "accepted" };

// Inputs are already validated; customerId comes from the authenticated user.
export async function createOrder(
  customerId: string,
  key: string,
  input: OrderInput,
) {
  // Include every field that changes what the operation does.
  const payload = JSON.stringify({ sku: input.sku, quantity: input.quantity });

  const orderId = randomUUID();
  const response: CreationResponse = { orderId, status: "accepted" };

  // 1. Try to create the order. The unique constraint blocks duplicates.
  const inserted = await pool.query<{ creation_response: CreationResponse }>(
    `INSERT INTO orders (
       id, customer_id, idempotency_key, request_payload, creation_response
     )
     VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
     ON CONFLICT (customer_id, idempotency_key) DO NOTHING
     RETURNING creation_response`,
    [orderId, customerId, key, payload, JSON.stringify(response)],
  );

  // 2. We won: this is a brand-new order.
  if (inserted.rowCount === 1) {
    return { status: 201, body: inserted.rows[0].creation_response };
  }

  // 3. The key already exists. Look up what happened the first time.
  const existing = await pool.query<{
    same_request: boolean;
    creation_response: CreationResponse;
  }>(
    `SELECT request_payload = $3::jsonb AS same_request, creation_response
     FROM orders
     WHERE customer_id = $1 AND idempotency_key = $2`,
    [customerId, key, payload],
  );

  const previous = existing.rows[0];

  if (!previous) {
    // Never silently fall through to creating a second order.
    throw new Error("Idempotency record unavailable");
  }

  // 4. Same key, different request: the client made a mistake.
  if (!previous.same_request) {
    return { status: 409, body: { error: "Key already used with different input" } };
  }

  // 5. A true retry: return the original result.
  return { status: 201, body: previous.creation_response };
}

What this code does, in plain English

  1. Try to insert. The unique constraint guarantees at most one row per customer and key.
  2. Inserted? It's a new order, so return 201.
  3. Not inserted? Someone already used this key, so read what they stored.
  4. Different payload? Return 409. The key was reused for a different request.
  5. Same payload? Return the original response. The customer sees the same order ID as the first time.

💡 Many APIs also add a response header such as Idempotent-Replayed: true so clients can tell a replay from a fresh creation.

Why the separate SELECT?

Under PostgreSQL's default READ COMMITTED isolation, ON CONFLICT DO NOTHING can be blocked by a row that the INSERT statement itself can't see. A new statement takes a fresh snapshot, so it can see that row. The PostgreSQL docs explain the details.

Writing to more than one table?

Wrap all the related writes in one explicit transaction. With node-postgres, every statement in that transaction must use the same checked-out client from the pool, not pool.query().

Also set bounded database timeouts. A request waiting on a competing request shouldn't hold resources forever.

What this guarantees, and what it doesn't

✅ Guaranteed: for a given customer and key, at most one order is created, for as long as the key is stored.

❌ Not guaranteed:

  • that the customer received the response
  • that payment succeeded
  • that inventory was reserved
  • that a confirmation email was sent
  • that two different keys really represent two different purchases

The replay returns the original creation response. If the client needs the order's current state, such as shipped or cancelled, that's a separate GET /orders/:id.

Side effects outside your database

A PostgreSQL transaction can't include an HTTP call to a payment provider. If you charge the card and then crash, the database doesn't know about the charge.

A common fix is the transactional outbox pattern:

┌───────── one DB transaction ─────────┐
│  INSERT order                        │
│  INSERT outbox_event (charge card)   │
└──────────────────────────────────────┘
                  │
                  ▼
        Worker reads outbox → calls provider → retries on failure

The worker may deliver an event more than once, so consumers must deduplicate too. It's idempotency all the way down.

For payments, use the provider's own idempotency mechanism with a stable payment-operation ID. If the provider call times out, check the payment's status or reconcile it before trying again.

Keys that expire change the guarantee

The example above keeps keys forever. If you move them into a separate table and delete old ones, a very late retry will look brand new.

Stripe, for example, documents that keys may be removed once they're at least 24 hours old. That's Stripe's contract, not a universal rule.

Rule of thumb: keep keys at least as long as your longest realistic retry window. If the business needs permanent uniqueness, enforce a durable business ID, such as a checkout ID, separately from the expiring key store.

Test the scary cases

Scenario Expected result
Two simultaneous requests, same key One order; both get the same successful response
Same key, changed quantity 409; original order unchanged
DB commits, HTTP response is lost Retry returns the original order ID
Insert rolls back before commit Retry creates the order normally
Client restarts before retrying Persisted key is reused
Key expired (if you clean up) Behavior matches your documented retention

The most revealing test is the lost response: let the database commit, kill the connection before the response arrives, then retry. If you get a second order, you have a bug that's waiting for production traffic to find it.

Checklist

  • ✅ Key generated once per operation and reused on every retry
  • ✅ Key persisted if retries must survive a reload or restart
  • ✅ UNIQUE (customer_id, idempotency_key) enforced in the database
  • ✅ Business write and replay response committed atomically
  • ✅ Same key with different input returns 409
  • ✅ Auth checked on every attempt, including replays
  • ✅ External side effects go through an outbox with idempotent consumers
  • ✅ Key retention covers your real retry window
  • ✅ Lost-response case covered by a test

A timeout is a question, not an answer. Your API should be able to answer it.

When a client can't tell whether its first attempt succeeded, can your API safely return the original result?

How does your team handle retries today? Let me know in the comments. 👇

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.