Skip to content

14 min read

Everything shown before the close is provisional

On this page

A backend commit is about a row, and it is enforced by a boundary you control: nothing downstream can read a record that was never inserted. A screen commit is about what a person now believes, and it has no such boundary, because a value that has been on screen for two seconds has already been read, remembered, acted on, and possibly forwarded. The interface cannot roll a belief back to the state before it.

So the only workable design is to tell the user in advance that what they are reading is not final, and then to be visibly explicit about the moment it becomes final or stops being true. That is a rendering contract rather than a parsing one, and it is missing from almost every progressive interface that was built by adding a stream to a page which used to wait — those pages have a loading state and a done state, and the state this chapter is about sits between them and looks exactly like done.

Three properties, each of which fails on its own

Every field shown before the object closes must carry a provisional marker that a user reads without effort — a distinct background, a "still writing" affordance next to the group, a container border that is unmistakably different from the settled one. There must be exactly one terminal transition, so the render either commits or is withdrawn, and never simply stops updating while looking finished. Withdrawal must be a state the UI was designed to enter, with layout, copy and next actions decided before the failure, rather than a branch that throws and leaves the last render standing.

The belief the first of those has to defeat is not held by an engineer, it is held by the user, and it is held for an entirely sound reason. Everywhere else in the product, a panel full of values is a finished panel, because everywhere else the values arrived in one response and the loading state ended at the moment they did. Somebody reading a streamed panel applies that rule, and the rule is correct about the rest of the product. This is why the provisional marker cannot be subtle, and why it cannot be introduced only at the point where something goes wrong: it is competing against a convention the interface has spent years teaching, and a convention is not overturned by a slightly paler shade around one box. It also explains the shape of the third property. A screen that throws and leaves the last render standing has not failed to communicate the failure — it has communicated the opposite, in the product's own established language.

The commit point is later than most implementations put it. Closing the object is a syntactic event, and topic 5.2, on constraining and validating model output, already established that a well-formed object can still fail its schema. Committing at the closing brace means promoting a value that a validation pass 3 milliseconds later may reject, and in a relay that also runs business rules — checking that surcharge codes exist in a tariff table, that a total equals the sum of its parts — that window is more like 200 milliseconds. Commit at the end of validation, and treat the interval between close and validation as still provisional, because it is.

Silence is not an event

A stream that has stopped sending looks exactly like a stream that is thinking. During a long structured generation the gap between deltas is usually 8 to 15 milliseconds, but the p99 gap in production is closer to 900 milliseconds, and a provider under load or a model that pauses between a reasoning block and a tool call can be quiet for 4 to 6 seconds with nothing wrong. An idle timeout of 2 seconds will therefore withdraw perfectly healthy renders several times an hour, and an idle timeout of 30 seconds leaves the user staring at a dead panel for half a minute. Neither number is right because the question is wrong: you cannot infer the end of a stream from the absence of bytes.

Read those gap figures together and the impossibility falls out without any judgement being required. A typical gap is around ten milliseconds and the ninety-ninth percentile is close to a second, so the healthy distribution already spans two orders of magnitude before anything unusual happens. Add the pauses — a provider under load, a model that stops between a reasoning block and a tool call — and the top of the healthy range reaches several seconds. A timeout has to sit above the longest healthy gap, or it fires on working streams. It has to sit below the patience of the person watching, or it detects nothing anybody cares about. Those two constraints do not overlap here, because the longest healthy silence and the longest tolerable silence are the same order of magnitude. The two-second and thirty-second settings are not a badly chosen pair with a good value between them. They are the two available ways for a single threshold on this quantity to fail.

The remedy is to make the end an explicit message and to make silence itself measurable. Your relay sends a terminal event on every path, including the failure paths, and sends a heartbeat — a few bytes emitted on a timer that carry no data and exist only to prove the connection is still alive — while it is waiting on the upstream. In SSE a heartbeat is a comment line, which costs 8 bytes and is ignored by every conforming client:

text
: ping

event: result_end
data: {"status":"truncated","cause":"upstream_reset","fields_closed":6,"bytes":1841}

The heartbeat is best understood as a change of question rather than as a shorter timeout. Without one, the client is asking has the model produced a token recently, and the honest answer is that a healthy model may not have for several seconds. With one, it asks is my relay still there, which the relay answers on a schedule it controls and which says nothing at all about how the generation is going. That converts an unbounded healthy silence into a bounded one: the longest gap a working connection can now show is one heartbeat interval plus whatever slack the timer and the network contribute. Waiting for two intervals before withdrawing leaves margin for a single beat that was late or lost, and two intervals plus slack is where the figure below comes from. The eight bytes a beat costs are a colon, a space, four letters and the blank line that ends the event, which is why running one every ten seconds for the life of every connection is not a cost anybody has to model.

With a 10-second heartbeat, silence beyond about 25 seconds is a genuine transport failure rather than a slow model, and the client can withdraw on that timer with confidence. The number is not tuned, it is derived, so changing the interval changes it and leaves nothing else to guess at.

Writing the terminal event on the failure path takes deliberate effort, because the natural shape of a relay handler is to let the upstream exception propagate and close the socket. Catching it, emitting result_end, flushing, and only then closing is what turns a network failure into a message your UI can act on.

There is a trap in the obvious client for this. EventSource reconnects automatically when the connection drops, re-issuing the GET with a Last-Event-ID header, and if your endpoint is a plain generation endpoint rather than a resumable one, that reconnect starts a second full generation which you pay for and which the user never sees. It also means the transport quietly hides the very failure you are trying to detect. Streaming a structured result is usually better served by fetch with a ReadableStream, where a dropped connection surfaces as a read error you handle once, or by an SSE endpoint that is genuinely resumable in the sense topic 2.10 builds — a stored transcript with sequence numbers, where Last-Event-ID is a replay cursor rather than a request to start again.

Three states and exactly one way out of each

State diagram: start → Idle; Idle → Provisional (first field passes its gate); Provisional → Provisional (field added or extended); Provisional → Committed (result_end ok + object closed + schema valid); Provisional → Withdrawn (result_end truncated, heartbeat timeout, or validation failed); Committed → start; Withdrawn → start.

Modelling this as a discriminated union rather than a bag of booleans is what stops a fourth state from appearing by accident. A discriminated union is one type per phase, each carrying only the fields that phase can legitimately have, with a tag such as phase that tells the compiler and the reader which one they are holding. A component holding { data, loading, error } has eight representable combinations, six of which are nonsense, and the dangerous one is loading: false, error: true, data: <the last snapshot> rendered by a component whose first line is if (data) return <Quote …>.

The count is worth making explicit because it is the argument for the union rather than a flourish beside it. Those are three independent fields, each either meaningfully set or not, so the states the type admits are two possibilities raised to the third power. A type system will let a component construct any of them and a reducer will let any of them arise. Only a couple describe something that can genuinely happen; the rest are unreachable by intention and entirely reachable by accident, which is the standing definition of a defect that appears only in production. What the union changes is not the discipline but the arithmetic. There is no way to hold a value and an error simultaneously, because no member of the type carries both fields, so the dangerous combination is not guarded against — it has no representation to be guarded.

ts
type Withdrawal =
  | { cause: "truncated"; stopReason: "max_tokens" }
  | { cause: "upstream_reset"; retriesLeft: number }
  | { cause: "client_disconnect" }
  | { cause: "invalid"; issues: string[] };

type RenderState =
  | { phase: "idle" }
  | { phase: "provisional"; snapshot: Partial<Quote>; closed: Set<string> }
  | { phase: "committed"; value: Quote }
  | { phase: "withdrawn"; reason: Withdrawal; lastSnapshot: Partial<Quote> };

function reduce(state: RenderState, ev: StreamEvent): RenderState {
  switch (ev.type) {
    case "snapshot":
      return { phase: "provisional", snapshot: ev.snapshot, closed: ev.closed };
    case "result_end":
      if (ev.status !== "ok") return withdraw(state, ev);
      const parsed = QuoteSchema.safeParse(ev.value);      // topic 5.2
      return parsed.success
        ? { phase: "committed", value: parsed.data }
        : withdraw(state, { status: "invalid", issues: format(parsed.error) });
    case "heartbeat_timeout":
      return withdraw(state, { status: "upstream_reset" });
  }
}

Keeping lastSnapshot on the withdrawn state is deliberate, and it is not a licence to keep rendering it as a quote. It is there so the withdrawal screen can say the total we had reached was €4,180 and it was not final, which is a different statement from a total in a bordered box beside a "send to customer" button. The distinction between showing a value and presenting it as an answer is made in layout and copy rather than in the reducer.

Withdrawal by cause

The three causes from the previous chapter drive genuinely different experiences, because they differ in what the user can usefully do next. That is why classifying the cause during the stream pays for itself twice: once in the retry policy and once here.

CauseWhat the user seesAffordance offeredWhat the client does
max_tokensfields kept, greyed, labelled "cut off — not a complete quote""generate a shorter quote"never a plain retry; changes the request
Upstream reset, retries leftprovisional render held, marker changes to "reconnecting"noneretries behind the provisional state; user may never learn
Upstream reset, exhaustedpanel collapses to an error with the attempt id"try again"fresh request, fresh render
Client disconnectnothing — they are goneon return, the last attempt is listed as incompleteaborts the upstream call
Validation failedfields removed, error naming the failing field"try again", plus a support linkdiscards the value entirely

The second row is the one that changes how the rest of the system has to behave. Retrying a reset while the panel is still provisional is the best experience available — the user sees a marker change instead of a failure — and it means the panel is about to be repopulated from a completely different generation whose field values will not match the ones already on screen. A UI that is happy to replace its provisional content wholesale can do this. A UI that merges each incoming snapshot into what it is already showing will produce a chimera of two generations, with the first attempt's transit days beside the second attempt's total. The rule that makes the retry safe is that a new attempt clears the render rather than merging into it, and attempts are identified so a late snapshot from the abandoned stream can be dropped rather than applied.

Validation failure deserves the harshest treatment on the list because it is the case where the object is complete and wrong. There is a strong pull toward showing the fields that did validate, on the reasoning that seven good fields are better than nothing, and it recreates the manufactured-data problem from the write path on the screen instead of in the database. A quote whose surcharges do not sum to its total is not a quote with one bad field.

Nothing provisional leaves the page

The marker is a claim to the user, and the code has to make the same claim to every other system that might read the render. Provisional content that escapes the boundary arrives somewhere with no marker attached.

The escape routes are ordinary interface features. A copy button, an export to PDF, a "share this result" link, an integration that posts the panel into a chat channel, and the analytics event that ships field values to your warehouse all read the current render, and all of them must be disabled or must read from the committed value only. Form fields are the subtlest of these: pre-filling an editable input with a provisional value converts it into user-authored data the moment they submit, and the provenance is gone. Either leave the input empty until commit or make submission wait for it.

Automated readers are the other half. An agent, a browser extension, or a scraping integration that reads your DOM will read the provisional numbers with no idea they are drafts, which is the same failure as an automated consumer acting on a snapshot, arriving through a different door. Where machines read your page, the committed value should be exposed through something with an explicit status — a JSON endpoint, a data attribute carrying the phase — rather than through the rendered text.

Optimistic UI rolls back to something the user knew

Provisional rendering resembles optimistic UI and behaves differently in the way that matters. Optimistic UI shows the result of an action you initiated, against a state you already knew, and rollback restores something the user has seen before and understands: the like reverts, the row reappears, the counter goes back to 41. Withdrawing a provisional render rolls back to nothing, and the content being withdrawn is information the user did not have before and cannot unlearn. That is why the marker has to be there from the first pixel; there is no equivalent of a silent revert.

It is also not a loading indicator. A spinner says a result is coming, and the provisional marker says a result is here and may be wrong, which is a claim a spinner cannot make and a finished-looking panel actively contradicts. The two can coexist in one panel — a skeleton in the slots that have nothing yet, a provisional marker on the slots that do — and conflating them produces the worst available reading, in which a field with a value in it looks as settled as a field on a page that finished loading.

A progressive interface makes a claim on every frame, and the claim it makes by default is the wrong one. Left alone, a panel that has stopped receiving bytes says this is your answer, because that is what a panel full of values says everywhere else in the product. Marking the provisional phase, transitioning out of it on an explicit message, and designing the exit that removes content are what change the default claim to a true one.

Nor does any of this address the case where the stream is healthy and the render is still misleading, because fields are arriving into a list whose items keep changing places under the user's cursor. Withdrawal governs the whole render; what happens to individual slots inside a healthy provisional phase is a separate mechanism, and it is where the visible jitter in progressive interfaces comes from.

Streaming structured output — read the current version at https://daviddominguez.dev/learn/en/streaming-structured-output/everything-shown-before-the-close-is-provisional

Back to top