--- title: "Thinking in Systems, Beyond the Framework" date: 2026-03-02 category: "Guide" --- # Thinking in Systems, Beyond the Framework Out-of-order responses, duplicate orders, and stale caches: a practical guide to the state, timing, and failure problems hiding behind simple interfaces. *By Pranav Gawande. Published 2026-03-02, updated 2026-11-05. https://pranv.xyz/blog/thinking-in-systems* A user is filtering a list of projects. They click **Design**, change their mind, and quickly click **Engineering**. The list shows engineering projects, and then, half a second later, switches back to design projects. The Engineering button is still highlighted. Nothing appears in the console, and every request returned `200 OK`. Every piece of this feature works. The component renders correctly, the API returns the right data for each query, and the database is fine. The bug only exists in how the pieces interact over time. Knowing React, Next.js, or any other framework tells you how to build each piece. It doesn't tell you which of two correct responses deserves to be on the screen. That's a systems question. This article is about asking those questions on purpose: who owns a piece of data, what happens when events arrive in an unexpected order, what a failure actually means, and how old information can get before it misleads someone. ## Follow one action through the whole system The most useful habit here is also the simplest: pick one user action and trace it from the click all the way to the database and back. > **Figure 1.** Following one save through the system. Each place knows something different at the same moment. > > A flow from browser to network to API to database. The browser form holds the draft the user typed. The request in flight may arrive, fail, or be lost. The API checks whether the change is allowed. The database holds the saved value and is the source of truth. Below, caches, CDNs and other open tabs hold copies that may still show the old value. Take saving a profile. The form holds what the user typed. The request carries it across the network. The API checks it and writes it to the database. Meanwhile, a cache, a CDN, or another browser tab may still be holding the old value. Each of these places knows something different at any given moment, and that's normal. Bugs appear when the interface treats them as if they all knew the same thing. Tracing an action also exposes vague words. When the button changes to "Saved", what exactly happened? Was the request sent? Did the server accept it? Did the database commit it? Can other people see it yet? Those are four different moments, and some of them can happen without the others. A good interface picks one meaning for "Saved" and waits for that moment before saying it. ## Every piece of data needs an owner For each value on the screen, ask who owns it: which place has the authority to say what it is right now. The database owns the saved display name. The form owns the draft the user is typing. Those are two different values, and both are legitimate. Trouble starts when a component copies server data into local state and then treats the copy as the truth: ```tsx function ProfileForm({ user }: { user: { name: string } }) { // useState reads user.name once, on the first render. // If `user` is refetched later, `name` keeps the old value. const [name, setName] = useState(user.name); return setName(e.target.value)} />; } ``` This is fine as long as everyone understands that `name` is a draft. It becomes a bug when another part of the interface assumes `name` still matches the server. The fix is a decision rather than a library: the draft is explicitly a draft, the saved value comes from the server, and the code compares the two on purpose, for example to show an "unsaved changes" warning, instead of letting them quietly drift apart. ## Time is an input Back to the filter. Here's what happened, laid out on a timeline. > **Figure 2.** Two correct responses, wrong order. The slower request finishes last and overwrites the screen. Timings are illustrative. > > A timeline with time flowing downward. At 0 ms the user clicks Design and request A starts. At 150 ms the user clicks Engineering and request B starts. At 420 ms response B arrives and the screen correctly shows Engineering results. At 780 ms response A arrives and the screen switches to Design results, even though the Engineering button is still selected. Network requests aren't guaranteed to finish in the order they started. The Design query might hit a slower database index, take a longer route, or wait behind another request. If the code displays whichever response arrived most recently, it displays the wrong one. What the interface actually needs is narrower: show the response that matches the user's current selection. One framework-free way to enforce that is to number each request and ignore any result that isn't from the latest one: ```ts let latestRequestId = 0; async function showProjects(category: string) { const requestId = ++latestRequestId; const response = await fetch(`/api/projects?category=${encodeURIComponent(category)}`); const projects = await response.json(); // A newer request started while this one was in flight. Drop the result. if (requestId !== latestRequestId) return; renderProjects(projects); } ``` In React, an effect's cleanup function does the same job. This is the pattern [React's documentation recommends](https://react.dev/learn/synchronizing-with-effects#fetching-data), with an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) added so the browser can also stop the outdated request: ```tsx useEffect(() => { const controller = new AbortController(); let ignore = false; fetch(`/api/projects?category=${encodeURIComponent(category)}`, { signal: controller.signal }) .then((response) => { if (!response.ok) throw new Error(`Request failed with ${response.status}`); return response.json(); }) .then((data) => { if (!ignore) setProjects(data); }) .catch((error) => { if (!ignore) setError(error); }); return () => { ignore = true; controller.abort(); }; }, [category]); ``` Each run of the effect gets its own `ignore` variable. When `category` changes, React runs the previous run's cleanup first, so the old request's flag flips to `true` and its result is thrown away whenever it arrives. Aborting saves bandwidth, but the flag is what guarantees correctness, because not every asynchronous step after a fetch can be cancelled. Data-fetching libraries such as [TanStack Query](https://tanstack.com/query/latest) solve this for you by tying each result to the query that produced it, but it helps to know which problem they're solving. The same issue shows up in search-as-you-type, autocomplete, tab switching, and anything that refreshes in the background. Once time is involved, "the latest response" and "the response to the latest request" are two different things. ## A failed request is not always a failed operation Here's a more expensive version of the same problem. A user taps **Place order**. The server writes the order to the database and sends back a success response. On the way back, the user's phone switches from Wi-Fi to mobile data and the connection drops. From the browser's point of view, the request failed. From the database's point of view, the order exists. If the interface says "Something went wrong, please try again", the user taps again, and now there are two orders. Some operations are safe to repeat. Fetching a list twice gives you the same list. HTTP defines methods like `GET`, `PUT`, and `DELETE` as [idempotent](https://www.rfc-editor.org/rfc/rfc9110#name-idempotent-methods), meaning that sending the same request twice has the same effect as sending it once. `POST`, which usually creates something new, is not idempotent. The standard fix is an **idempotency key**. The client creates a unique ID once per user intent, for example when the checkout page opens, and sends that same ID with every attempt to place the order. The server remembers which keys it has already handled. A retry with a known key gets the original result back instead of doing the work again. > **Figure 3.** With an idempotency key, the retry finds the order that already exists instead of creating a second one. > > A sequence between browser, API and database. The browser sends POST /orders with Idempotency-Key 7f3a. The API inserts the order and the database commits it. The 201 response is lost when the connection drops, so the browser shows that it could not confirm the order and retries with the same key. The API's insert hits the unique key and does nothing, it reads the existing order, and returns 201 with the same order number. One order exists. [Stripe's API](https://docs.stripe.com/api/idempotent_requests) is a well-documented example. It saves the status code and body of the first request made with a key, returns that saved response to any retry, and rejects a reused key whose parameters don't match the original. An [IETF draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) standardizes the `Idempotency-Key` header itself. The tricky part on the server is concurrency: two retries can arrive at the same moment, and both can check "have I seen this key?" before either has saved it. Instead of checking in application code, let the database enforce uniqueness. Here's a sketch with PostgreSQL: ```sql create table orders ( id uuid primary key default gen_random_uuid(), idempotency_key text not null unique, customer_id uuid not null, total_cents integer not null, created_at timestamptz not null default now() ); ``` ```ts // app/api/orders/route.ts, where `db` is a node-postgres Pool. // A real handler would also authenticate the user and validate the body. export async function POST(request: Request) { const key = request.headers.get("Idempotency-Key"); if (!key) { return Response.json({ error: "Idempotency-Key header is required" }, { status: 400 }); } const { customerId, totalCents } = await request.json(); // The unique constraint means only one insert per key can ever succeed, // even when two retries arrive at the same moment. await db.query( `insert into orders (idempotency_key, customer_id, total_cents) values ($1, $2, $3) on conflict (idempotency_key) do nothing`, [key, customerId, totalCents], ); const { rows } = await db.query( "select id, customer_id, total_cents from orders where idempotency_key = $1", [key], ); const order = rows[0]; if (order.customer_id !== customerId || order.total_cents !== totalCents) { return Response.json({ error: "This key was already used for a different order" }, { status: 422 }); } return Response.json({ orderId: order.id }, { status: 201 }); } ``` On the client, the key has to be created once per intent, not once per tap: ```tsx // One key per checkout. Every retry of this order reuses it. const [idempotencyKey] = useState(() => crypto.randomUUID()); ``` The interface needs an opinion about uncertainty, too. When the response is lost, the honest message isn't "Order failed". It's "We couldn't confirm your order", followed by a status check or a safe retry with the same key. Not knowing is a real state, and it deserves its own screen. > **Figure 4.** Three screens for one tap. The middle one is the state most interfaces forget to design. > > Three phone screens. Saving: placing your order, request sent with key 7f3a. Unknown: we couldn't confirm your order, checking with the server, there's no need to tap again. Saved: order 1042 placed, one order even after the retry. Automatic retries need limits as well. They should only apply to operations that are safe to repeat, stop after a few attempts, and wait a little longer each time, with some randomness mixed in so thousands of clients don't retry in lockstep right after an outage. AWS's article on [timeouts, retries, and backoff with jitter](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/) explains why that randomness matters. ## Freshness: how old can this data be? Caches make applications feel fast. They also mean some of the data on screen is a copy, and copies get old. How old is acceptable depends on what the data is for. A blog post can be an hour old without anyone noticing. A price can be a few minutes old if checkout recalculates it. The number of seats left on a flight can come from a cache while someone browses, but the purchase itself must check the database, because that's the moment where a stale number costs someone money. HTTP gives you vocabulary for this. A response sent with this header: ```text Cache-Control: max-age=60, stale-while-revalidate=300 ``` is fresh for 60 seconds. For the next 300 seconds, a cache may keep serving the old copy while it fetches a new one in the background. [RFC 5861](https://www.rfc-editor.org/rfc/rfc5861) defines that `stale-while-revalidate` behavior. Trading a little correctness for speed is exactly right for some data and dangerously wrong for other data. The interface can help. "Updated 2 minutes ago" next to a number tells people how far to trust it. If a background refresh fails, keeping the last good value while clearly marking it as out of date is usually better than replacing it with an error, and much better than quietly presenting it as live. ## Write the states down Many of the bugs in this article come from states that nobody wrote down. A save button driven by three booleans, `isLoading`, `isError`, and `isSuccess`, can technically be loading and successful at the same time, and it has no way to express "we don't know". It helps to list the states explicitly, along with the events that move the interface between them. > **Figure 5.** A save button's states, including the one most interfaces forget: not knowing. > > A state diagram. Idle goes to Saving on submit. Saving goes to Saved when the server confirms, to Failed when the server rejects the change, and to Unknown when no response arrives. Unknown goes back to Saving by checking the status or retrying with the same idempotency key. Failed goes back to Saving when the user edits and submits again. Saved goes back to Idle when the user makes another edit. In TypeScript, a discriminated union turns that diagram into a type. Because each state is a separate object shape, impossible combinations can't be represented, and TypeScript can check that a `switch` over `status` handles every case: ```ts type SaveState = | { status: "idle" } | { status: "saving"; idempotencyKey: string } | { status: "saved"; savedAt: Date } | { status: "failed"; message: string } // the server said no | { status: "unknown"; idempotencyKey: string }; // no answer arrived ``` The `unknown` state is the important one. It keeps the idempotency key, so the code can check on the operation or retry it safely, and the interface can say something true: "We couldn't confirm your changes were saved. Checking now." ## Test the timing, not just the happy path A feature that works when everything is fast and reliable has only been tested under the least interesting conditions. A handful of scenarios cover most of the risk: - An older response arrives after a newer one. - The server completes a write, but the response never arrives. - The user double-clicks a submit button. - The page reloads while there are unsaved changes. - The connection drops while the screen is showing live data. You don't need special tools for most of these. Browser developer tools can throttle the network or switch it off, and a test server can delay specific responses to force them out of order. For each scenario, decide in advance what the user should see, then check that they see it. ## Where the framework fits None of this makes frameworks less important. React's effect cleanup, TanStack Query's query keys, Next.js's caching controls, and PostgreSQL's unique constraints are the tools that carry out these decisions. But a framework can't decide what "saved" means in your product, how old a price is allowed to be, or what to tell someone when the network goes quiet. Those are engineering decisions, and the code has to preserve them. The next time a feature works in the demo but feels fragile, trace one action and ask four questions. Who owns this data? What happens if responses arrive out of order? What happens if a response never arrives? How old can this information be before it misleads someone? Answering them turns most "random" bugs into predictable ones. For a concrete example of cancellation and cleanup in a browser app, [the FreePDF case study](/blog/privacy-first-pdf-editor) shows how a PDF tool releases memory and workers when an operation fails or the user leaves. And if you're deciding how much of this belongs in a first release, [Shipping Faster Starts with a Smaller Promise](/blog/guide-to-shipping-fast) is about exactly that.