TT Lab
Get started
Learn Learning paths Courses

State Management — Build the Library Yourself

The Request You Sent First Arrives Last

Continue in TT Lab

In one line

In asynchronous updates, there is no guarantee that a request sent later arrives later. So any place that modifies state must have a mechanism that asks "is this response still valid?"

Why this was needed

You type 서, 서울, and 서울역 into a search box one after another (Korean place-name queries). Three requests go out, and the screen should end up showing the last result. But some days the result for 서 is what stays on screen.

The reason is simple. The first request missed the cache and took 800ms, while the third request came back in 40ms. The third result reached the screen first, and 800ms later the first response arrived and overwrote it. Nothing in the code looks wrong.

const res = await fetch("/search?q=" + q)
setResults(await res.json())    // 누가 언제 도착했는지 아무도 묻지 않는다

The nasty thing about this bug is that it doesn't reproduce on the developer's machine. Locally the response takes 5ms, so there is no window for the order to flip. Only users on the subway see it.

There are three more accidents of the same kind. Four places on the screen each request the same data, so the same request goes out four times; a rollback is built wrongly and undoes other people's updates too; and a request that was never canceled keeps touching the state of a screen that no longer exists.

How it works

Latest wins — attach a sequence number

The cheapest and surest method is to number each request and, before applying a response, check whether that number is still the latest.

let seq = 0
async function run(task) {
  const my = ++seq
  const value = await task()
  if (my !== seq) return { applied: false }   // 나는 이미 낡았다
  return { applied: true, value }
}

What matters is that discarding is the default. Write it not as "ignore the one that arrives late" but as "apply only when I am the latest," and the same rule stands whether there are three requests or ten.

Cancellation — discarding the result is not the same as stopping the work

Filtering by number keeps the screen correct, but the network and the server keep working. The browser standard provides AbortController for this. MDN says abort() aborts fetch requests, any response body consumption, and streams (AbortController). If you cut the previous request's signal when you start a new one, two things are solved at once — stale responses stop arriving, and the server gets its slot back.

Deduplication — one request per key

If four places on the screen need the same user/42, four requests go out. If you remember the in-flight request by key and hand out the same Promise, it drops to one. This is what TanStack Query does by default, and the same doc says a failed query is retried three times with exponential backoff (Important Defaults).

Optimistic updates — a rollback is not a subtraction

When the like button is pressed, the screen changes first without waiting for the server response. If it fails, you roll it back. Nearly everyone makes the same mistake here — writing the rollback as an inverse operation.

like()            // count + 1
unlike()          // 실패하면 count - 1

If someone else pressed like in the meantime and the server value changed, the subtraction produces a wrong value. It is worse for ordered updates. If an update that changes a label to A and an update that changes it to B are both pending, rolling back the earlier one should leave the label as B, but the inverse operation reverts it to the state before A.

The right way is to keep the confirmed base value and the list of pending updates separately, and always compute the screen by replaying the pending list, in order, on top of the base value. A rollback is removing that entry from the list, and when the server response arrives, you replace the base value and then replay the remaining pending list on top. React's useOptimistic has the same shape — you give it a confirmed value and a reducer, and it shows the pending updates layered on top (useOptimistic).

What it looks like in the field

A dashboard with fast tab switching received a report: "sometimes old tab data shows up." Each tab sent a request and put the response straight into state, and when you switched between two tabs quickly, the first tab's response arrived later. It couldn't be reproduced and dragged on for over a month, until it was finally reproduced at once in a browser with the network throttled. The fix was three lines of sequence comparison.

Another team had written the like rollback as a subtraction, and for the few seconds the server returned a 500, it also knocked down other users' likes, and the count went negative. It would not have happened if the rollback had been not a "subtraction" but "remove from the pending list and recompute."

Both accidents happened not in the screen code but because the place that modifies state had no notion of order. Even if you change libraries, that follows you if that place has no order.

What you will do in the next lab

In a single lane.mjs, you build four things by hand — a latest-wins lane, a cancellation signal, per-key deduplication, and an optimistic update computed from a base value and a pending list. Then at the end you run the same scenario yourself and write down the numbers, and the grader re-measures on the spot to compare. Made-up numbers will not pass.