Skip to content

13 min read

Stable keys, monotonic updates, and no flicker

On this page

A reconciler — the part of a UI framework that compares the previous render with the next one and decides which elements to keep, which to move, and which to destroy and rebuild — works under the same contract a database has with a primary key: it can only tell you two renders are the same thing if you give it something that names the thing. A streamed list breaks that contract in a way a static list does not, because every snapshot from an incremental parser is a new array, often in a new order, and the reconciler has to decide what moved and what is new.

This chapter is about the three mechanisms that keep a progressive render legible while it is still filling: where identity comes from, what an update is allowed to do to what the reader has already read, and how the layout stops moving. All three live inside the provisional phase — they govern how slots fill, not whether the whole render can later be withdrawn.

Identity has to be assigned by the producer

An array index names a position, so keying by index tells the reconciler that whatever is in slot 2 now is the same entity as whatever was in slot 2 before, which is true only while the list is append-only and stays in one order. Every insertion into the middle, every re-sort, every filter change breaks that claim silently, and the visible symptom is not an exception but a row that briefly wears another row's label, another row's expanded state, another row's animation.

The step from a key to a misplaced checkbox is the one usually skipped, and it is three links long. The reconciler is handed the previous list of keys and the next one, and the only question it asks is which keys appear in both. A key present in both means this is the same element, so the framework keeps the existing DOM node, keeps the component instance attached to it, and updates the props. A key present only in the new list means this is a new element, so it mounts a fresh instance. Put those together with where component state lives: a checkbox, an expanded toggle, an animation's progress and a text cursor all belong to the instance, and the instance belongs to the key. State therefore follows the key and not the data. Under key={i} the key is the position, so the instance sitting at position two keeps its checkbox while the data underneath it is replaced by whatever sorted into position two on this frame. Nothing is corrupted, nothing throws, and the framework has done exactly what the key instructed it to do.

The fix is a key that comes from the item rather than from its surroundings, and the options differ in whether they are available at the moment you first need them.

Keying strategyStable under insertionAvailable before the item closesSurvives a retryCost
Array index in display ordernoyesn/azero, and wrong
Model-emitted id fieldyesonly after that field closesyes, if the model repeats it~5 output tokens per item, plus a schema property
Arrival ordinal in the accumulating arrayyesyes, from the first byte of the itemno — a new attempt renumberszero
Hash of the item's contentnonoyeschanges on every delta

The content hash row is the tempting wrong answer, because hashing feels like the canonical way to derive identity from data. A streaming item's content changes on every chunk by construction, so its hash changes on every chunk, and keying by it tells the reconciler that every item was destroyed and replaced between frames — which unmounts the row, loses its state, and restarts its entry animation sixty times a second.

Hashing deserves a defence before it is dismissed, because it is a recommended technique elsewhere and the instinct behind it is sound. For an immutable list — rows that arrive complete and never change afterwards — a content hash is a genuinely good key. It survives reordering, it needs no cooperation from whoever produced the items, and two rows with identical content are interchangeable in any case. Every one of those properties rests on the content being final. A streaming item's content is by construction not final, so its hash is a value that changes on a schedule the network sets. Keying on it does not hand the reconciler bad identities. It tells the reconciler, correctly by the rule it was given, that every row was destroyed and a different one created in its place between one frame and the next.

Arrival ordinal is the one to reach for first, because it exists before any field of the item has closed and it is genuinely stable: item 3 in the accumulating array is item 3 for the rest of that stream, regardless of where you decide to display it. What it does not survive is a retry behind the provisional marker, and the previous chapter already settled that a retry clears the render rather than merging into it, so the ordinals of the abandoned attempt never meet the ordinals of the new one. Combining the attempt id with the ordinal — `${attemptId}:${i}` — makes that explicit and costs nothing.

A model-emitted id is worth adding to the schema when the items outlive the stream: when the user's selection is persisted, when the row links to a record, when a later request refers back to a specific item. Asking for it is a schema property like any other, and with schema-constrained generation the model will emit it in declared order, so putting id first in the item's property list means the identity closes before any of the content does.

tsx
// Before: position is identity, and position is a function of everything else.
{results.sort(byScore).map((r, i) => <Row key={i} item={r} />)}

// After: identity is assigned when the item first appears and never reassigned.
{results.map((r, i) => ({ ...r, key: r.id ?? `${attemptId}:${i}` }))
        .sort(byScore)
        .map((r) => <Row key={r.key} item={r} />)}

The second version still re-sorts, and the sorting is now a purely visual movement of rows that keep their identity, their checkbox and their scroll anchoring. That is a large improvement and it is not the whole fix, because a row that moves under a pointer that is already travelling toward it will still receive a click meant for its neighbour.

Only add information

Sorting during the stream is a special case of a general property worth enforcing directly: within the provisional phase, each update should only ever add information to what is already on screen. A field that was present stays present, a string that was rendered only ever grows at its end, an item that appeared does not disappear, and a slot that showed a value does not go back to showing a skeleton, the grey placeholder block that stands in for a value which has not arrived. Users read while the stream runs, and a render that removes or rewrites something they have already read costs them a re-read at best and misleads them at worst.

The snapshot logic from earlier in this topic gets you most of the way there, because a repaired snapshot is a faithful prefix of the document, and prefixes grow. Regressions still get in from four directions. The repair rules drop unterminated numbers and partial literals, so a permissive parser configured to keep them will show 12 and then 1200, which is a value replacement rather than an extension. Snapshots produced asynchronously — decoded off the main thread, or posted from a worker — can arrive out of order, so a stale snapshot applied after a fresher one erases fields. A re-sort is a reordering, which is information removal in the reader's terms even if no bytes were lost. And a retry substitutes a whole new generation, which is legitimate precisely because it is not an update to the existing one.

The third of those four is the one people argue with, so it is worth being exact about what a re-sort removes. Nothing in the data disappears: every item is still present, carrying every field it had a moment earlier. What disappears is the reader's index into it. Somebody who has located a row by its position — and anybody pointing at a row has located it by position, whatever they believe they are doing — is holding a fact that the next frame silently invalidates. Bytes are not the only thing a render can take away, and a reader's place in a list is the one they will not notice losing until they have already acted on the wrong row.

Making the property checkable is cheap, and it converts a class of bug that shows up as user confusion into a class that shows up in tests:

ts
function mergeMonotonic<T extends object>(prev: Partial<T>, next: Partial<T>): Partial<T> {
  for (const key of Object.keys(prev) as (keyof T)[]) {
    const a = prev[key], b = next[key];
    if (b === undefined) throw new Regression(`${String(key)} disappeared`);
    if (typeof a === "string" && typeof b === "string" && !b.startsWith(a))
      throw new Regression(`${String(key)} rewritten: ${a!.slice(-12)} -> ${b.slice(-12)}`);
    if (Array.isArray(a) && Array.isArray(b) && b.length < a.length)
      throw new Regression(`${String(key)} shrank ${a.length} -> ${b.length}`);
  }
  return next;
}

Run that as an assertion in development and as a counter in production, wired to drop the offending snapshot rather than to throw at a user. Feeding it the boundary-fuzzing test built earlier in this topic — the same payload sliced at every possible cut point, each cut producing a snapshot sequence — turns it into a property test: for every slicing of the document, the sequence of snapshots is monotonic and the final one equals the parsed document.

Reserving the space before the value exists

The other half of flicker is layout. A field that appears when it arrives pushes everything below it down, and a panel with six such fields moves its own content four or five times during a three-second stream, which is why a pointer and the row it is travelling toward end up disagreeing about where the row is. The cure is to draw the shape of the object before its values exist, from the schema, which you have.

Every declared property gets a slot at mount: a label, a fixed area sized for the value it will hold, and a pending treatment inside it. The size comes from what the schema tells you — an enum badge is as wide as its longest member, a currency amount is as wide as its formatted maximum, a summary described as two to four sentences gets three lines of skeleton at the panel's line height. When the value arrives it replaces the skeleton inside a box that does not change size, so the reflow is zero and the eye stays where it was. Where the shape is genuinely unknowable, such as an array whose length is not bounded by the schema, reserve the container and let it grow downward only, with the growth below anything interactive.

This is also where the render gates become visible in the layout. A field held back by a semantic gate has a slot that is deliberately empty for longer than the data required, and the pending treatment in that slot should not look like a network wait. Labelling it waiting on red flags rather than showing an animated shimmer tells the user that the system is holding something back on purpose, which is the truthful description of what is happening and is a better answer to "why is that box still empty" than a spinner.

The last ingredient is update frequency. Taking a snapshot every 50 to 100 milliseconds rather than every chunk was a CPU argument earlier in this topic, and perception argues for the same number from the other side. Text that updates at 60 hertz reads as vibration rather than as writing; the same text updated at 10 to 20 hertz reads as typing, and no information is lost because a human cannot read faster than the words arrive. Coalescing also removes a whole family of intermediate states — the field that was briefly Refun, the array that briefly had one element — by never rendering them.

Those two framings are one number read from either end, and converting between them is a reciprocal. An update every 50 to 100 milliseconds is between ten and twenty updates a second, because a rate is one divided by an interval. Sixty updates a second is one every sixteen or so milliseconds, which is below the interval at which the eye resolves changes as separate events, so consecutive frames blend into a shimmer instead of reading as writing. Slowing to ten or twenty puts enough distance between updates that each one reads as an event, which is what makes the same text look like typing. Nothing is lost in the slowing, because the words are not arriving faster than that in the first place and a coalesced update carries every character that landed during its interval.

Monotonicity does not govern the phase transitions

Monotonicity governs the inside of the provisional phase and says nothing about the phase transitions from the previous chapter. Withdrawal removes everything, which violates every rule in this chapter and is correct, because it is a transition of the whole render rather than an update to a slot. Keeping the two mechanisms distinct in the code — a merge function that refuses to lose information, and a reducer that can move the whole state to withdrawn — is what prevents a well-meaning implementation from treating withdrawal as an update and finding it blocked by its own invariant.

It is also not a claim that the model's early output was right. A ranked list is a genuine problem for this: if the model emits candidates in the order it considers them and the ranking is a property of the finished set, then any order you display before the array closes is a claim you will have to revise. Stable keys make the revision smooth, and smooth is not the same as honest. The gate rule applies to the aggregate rather than the elements — display rank, "top match", and any ordering that carries meaning only after the closing bracket, and show items in arrival order until then, which is monotonic by construction.

Identity, monotonicity and reserved space are three answers to one question: what does a frame of a half-arrived object claim, and does the next frame contradict it. A row keyed by position claims that slot 2 is a person. An update that reorders claims that what the reader just read was provisional in a way the marker never said. A field that appears and pushes the page down claims nothing at all and still costs the reader their place. None of the three is expensive to get right in advance, and all three are expensive to retrofit, because by then component state is keyed on the assumptions.

Nothing here is free, and the bill is the reason this topic ends where it does. Stable keys, a monotonic merge with assertions, reserved slots derived from the schema, coalesced updates, and the whole provisional state machine are a few hundred lines of client that exist because a human being is watching bytes arrive. When nobody is watching, all of it is cost with no counterpart, which makes the first question about any stream a question about its consumer.

Streaming structured output — read the current version at https://daviddominguez.dev/learn/en/streaming-structured-output/stable-keys-monotonic-updates-and-no-flicker

Back to top