Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Optimistic UI encourages retrying failed mutations because the UI already shows the desired state — the user just wants the server to agree. But retrying a non-idempotent mutation is dangerous: incrementing a counter twice because a network timeout made you think the first request failed means your data is wrong. The solution is idempotent API design: either use PUT (set the value absolutely) instead of increment operations, or attach a client-generated idempotency key to POST requests so the server recognizes and deduplicates retries. Every mutation that will be paired with optimistic UI should be designed to be safely retried, because network timeouts and flaky connections will happen in production.
Two implementations of the same 'set view count' feature: a non-idempotent POST that adds 1 each time it is called, and an idempotent PUT that sets the value absolutely.
import { useState } from "react";
// NON-IDEMPOTENT: calling this twice increments twice
async function nonIdempotentIncrement(postId: string): Promise<void> {
await new Promise((r) => setTimeout(r, 400));
if (Math.random() < 0.3) throw new Error("timeout");
// server: UPDATE posts SET views = views + 1 WHERE id = ?
}
// IDEMPOTENT: calling this with the same value sets it absolutely
async function idempotentSetCount(postId: string, count: number): Promise<void> {
await new Promise((r) => setTimeout(r, 400));
if (Math.random() < 0.3) throw new Error("timeout");
// server: UPDATE posts SET views = ? WHERE id = ?
}
export function NonIdempotentCounter({ postId }: { postId: string }) {
const [count, setCount] = useState(100);
async function handleClick() {
const prev = count;
setCount((c) => c + 1); // optimistic
try {
await nonIdempotentIncrement(postId);
} catch {
setCount(prev); // rollback; but if we retry, server increments again
console.warn("Non-idempotent: retrying would double-increment");
}
}
return <button onClick={handleClick}>Views: {count}</button>;
}
export function IdempotentCounter({ postId }: { postId: string }) {
const [count, setCount] = useState(100);
async function handleClick() {
const newCount = count + 1;
const prev = count;
setCount(newCount); // optimistic
try {
await idempotentSetCount(postId, newCount);
} catch {
setCount(prev); // rollback; safe to retry with same newCount
}
}
return <button onClick={handleClick}>Views: {count}</button>;
}NonIdempotentCounter, set the failure rate to 1.0 and add a retry in the catch block — add console.log('attempt') to verify two API calls fire per click, showing the double-increment hazard.IdempotentCounter, add automatic retry logic in the catch block (up to 3 times with 200ms delays) — verify it is safe because it always sends the same absolute newCount value.crypto.randomUUID()) inside handleClick and log it for both the original attempt and any retries — verify the same key is used each time.idempotentSetCount to log the count argument it receives — click once with a failure configured, then manually retry, and verify both calls log the identical target count.Use these three in order. Each builds on the one before.
In one paragraph, explain what idempotency means for an API endpoint and why it matters specifically when optimistic UI mutations need to be retried.
Walk me through how an HTTP PUT differs from a POST increment at the database query level, and explain why PUT makes the operation safe to call multiple times with the same payload.
Design an idempotency key scheme for a POST /orders endpoint where the same client may submit the same order multiple times due to network timeouts — specify how the key is generated, where it is stored, and how the server deduplicates.