AI agents

A Streaming UI State Machine: Close, Partial and Error States

Antonio Evans10 min read

A cutaway view of a chat panel on a desk, drawn as a simple state diagram overlaid on the screen.
Key points

Key points

  1. 1A streamed structured-output panel needs seven explicit states (idle, connecting, receiving, partial, closed, error, stalled), not the two most teams ship.
  2. 2Every state change should be triggered by either a transport event or a parser event, never by a timeout guess inside the component.
  3. 3Button availability should be a pure function of state: send only in idle and closed, cancel only in connecting and receiving, retry only in error and stalled.
  4. 4Recovery from a dropped stream is a decision about what to keep, and the right default is to preserve the last parsed partial and label it clearly as incomplete.

Why two states are not enough

Most streamed panels are built with a boolean. There is loading and there is done. That works in a prototype where the response is a few hundred bytes and never fails. It breaks the moment a response grows, because between "loading" and "done" there are at least four situations that need different UI and different button behavior.

The first complication is that a structured response arrives in two different senses. The transport can be open while the parser is still deciding whether it has seen a complete value. JSON.parse is all-or-nothing: it needs a balanced document and returns nothing until the closing brace appears, so wiring it to every chunk gives you a panel that stays blank and then pops in whole as this write-up on partial parsers explains. A streaming parser instead models the open positions in the document: strings mid-character, arrays mid-element, objects with unclosed braces per this incremental parser discussion.

The second complication is that the transport can fail after the parser has already produced useful data. If the connection drops mid-object you have a real partial object, and you have to decide whether to show it, discard it, or repair it.

The third complication is silence. A stream that has stopped sending chunks is indistinguishable from a stream that is thinking hard on the next token, unless you measure the gap between chunks yourself.

Four of those situations need retry available and none of them are "loading." That is the case for naming states explicitly.

The seven states and what each one means

Each state should have one observable definition, so a test can assert it and a developer can reason about it without reading the component.

StateMeaningEntered bySendCancelRetry
idleNo request in flight, nothing displayedInitial mount, or reset after view is clearedYesNoNo
connectingRequest sent, no bytes parsed yetUser submits, or retry pressedNoYesNo
receivingChunks arriving, latest parse produced no new complete valueFirst chunk with no yielded valueNoYesNo
partialAt least one complete value or field has been yielded and stream is openParser yields a value, or a field settlesNoYesNo
closedStream ended and the document parsed to completiondone from the reader plus successful final validationYesNoNo
errorStream ended without a complete document, or validation failedReader rejection, abort, or schema failureNoNoYes
stalledModel went quiet past the threshold, nothing needs to be torn down yetWatchdog fires while state is receiving or partialNoYesNo

Two details matter. First, receiving and partial are not the same thing: one says bytes are flowing, the other says the UI has something worth rendering. Conflating them means a skeleton screen stays up after the first field has arrived. Second, stalled is deliberately not error. The connection may still be alive, so you keep cancel available and reserve retry until you have actually torn the stream down. The WHATWG streams discussion of async iteration recommends checking an abort signal each iteration and throwing the signal's reason, which gives you a clean way to move from stalled to error deliberately as described in this streams issue.

Wiring transport events to state transitions

The reducer should be small enough to read in one sitting, and its inputs should be exactly two event sources: the fetch stream and the parser.

type State =
  | { k: "idle" }
  | { k: "connecting"; attempt: number }
  | { k: "receiving"; attempt: number; raw: string }
  | { k: "partial"; attempt: number; raw: string; value: unknown }
  | { k: "closed"; value: unknown }
  | { k: "error"; attempt: number; reason: string; lastValue?: unknown }
  | { k: "stalled"; attempt: number; raw: string; value?: unknown };

type Event =
  | { t: "submit" }
  | { t: "chunk"; text: string }
  | { t: "parse"; value: unknown; complete: boolean }
  | { t: "done" }
  | { t: "fail"; reason: string }
  | { t: "watchdog" }
  | { t: "retry" };

The transition rules, stated as a decision table rather than a switch you have to trace:

  • submit is only legal in idle or closed, and moves to connecting with the attempt counter reset or incremented.
  • chunk is only legal in connecting, receiving, partial or stalled. From connecting it goes to receiving unless the same tick produced a parsed value. From stalled it goes back to receiving or partial, which is how a pause self-heals without user action.
  • parse with complete: false moves to partial if a value was yielded, otherwise stays in receiving. With complete: true it moves to closed and ignores any later chunks.
  • done from the reader moves to closed only if the final validation passes, otherwise to error with the validation message.
  • fail always moves to error, keeping lastValue if one existed so the panel can show what it had.
  • watchdog is only legal in receiving or partial and moves to stalled. It never moves to error on its own.

The one rule that pays for the whole design: ignore events that are illegal in the current state instead of trying to handle them. A late chunk after closed gets dropped. A fail after closed gets logged but does not repaint the panel. This eliminates the class of bug where a race between the abort and the final chunk flips the UI back to an error state after a successful render.

For the watchdog, pick the threshold from measured inter-chunk gaps rather than from the total response time. Timeouts are the failure mode people handle worst, and the guidance for retry logic is to distinguish connection timeouts from read timeouts, because a read timeout mid-stream means you may already have shown partial content per this error-handling guide. A useful starting point is three times the median inter-chunk gap you observe in staging, floored at a couple of seconds so a slow first token does not trigger a false stall.

Deriving button availability instead of setting flags

The reason teams end up with stuck buttons is that each button gets its own boolean, set in four places. The fix is one function from state to a permissions object, memoized on the state value.

function controls(s: State) {
  const canSend = s.k === "idle" || s.k === "closed";
  const canCancel = s.k === "connecting" || s.k === "receiving" ||
                    s.k === "partial" || s.k === "stalled";
  const canRetry = s.k === "error";
  return { canSend, canCancel, canRetry };
}

Three rules make this survive contact with real users.

First, send is enabled in closed but not in partial. Letting someone fire a second request while the first is half-rendered produces two panels writing into one list. If your product genuinely supports queueing, model that as a separate queued state rather than loosening the rule.

Second, cancel must do two things and both must be idempotent. It aborts the request and it cancels the reader. fetch accepts an AbortSignal and controller.abort() cancels the in-flight request per this Stack Overflow answer. Separately, ReadableStream.cancel() returns a promise that resolves when the stream is canceled, and it throws a TypeError if the stream is locked, which is exactly what happens if your reader still holds it per the MDN cancel() reference. Disable the cancel button the instant it is pressed by moving to a state where canCancel is false, and keep that state until the abort settles.

Third, retry is only legal in error, and it must decide what happens to the partial. More on that in the recovery section.

Disabling a button is not the only affordance. Screen readers will still announce a disabled button, so pair the disabled attribute with an aria-describedby pointing at the reason text ("waiting for the current response to finish"). That is the cheapest accessibility win in a streaming panel and it costs one attribute.

A worked example: a 12 KB response at three chunk sizes

Numbers make the case for treating partial as a first-class state. Take a 12 KB structured response and assume the provider emits tokens fast enough that the network is not the bottleneck; the figures below come from a published benchmark of a naive re-parse-per-chunk approach against a stateful incremental parser on that document size reported here.

ApproachTotal time for a 12 KB documentUser-visible behavior
Buffer, parse once at the endOne wait, seconds longSpinner until the closing brace
Re-parse the whole prefix on every 5-character chunkRoughly 16.7 secondsJanky from a few KB upward, final chunks taking 19 to 20 ms each
Stateful incremental parserRoughly 43 msFields settle one at a time

The naive column is the one to internalize: it is not merely slow, it degrades superlinearly, and the report notes the approach became unusable around the 5 KB mark. Your prototype never sees 5 KB, so the bug ships.

For the state machine, that table translates into a concrete rule about what to render in each state:

  • connecting: skeleton, one row per top-level field in the schema, so the layout does not jump when values land.
  • receiving: same skeleton, plus a subtle activity indicator, because no value has settled yet.
  • partial: real values for the fields that are decided, skeleton for the rest. Strings can render progressively if the field is human-read prose and must wait for the closing quote if the field is an identifier that will be used to make a call.
  • closed: everything real, canSend true, focus returned to the input.
  • error: whatever was parsed, clearly marked, with retry.
  • stalled: last good partial, with a cancel button and a line of text saying the model went quiet.

That last distinction, prose streams but identifiers do not, comes from the parser design discussion of what to yield when a string is mid-character, and it is worth encoding in your schema rather than deciding per component per the same partial parser analysis. A half-typed argument passed into a tool call is worse than waiting one more chunk.

Recovering when the stream drops mid-object

A dropped stream gives you three options and you should pick one per field type, not one per app.

Option A, discard. Move to error, empty the panel, offer retry. Correct for anything where a partial value would be dangerous to act on, such as a payment amount or a tool-call argument that triggers an external side effect.

Option B, show what parsed and label it. Keep lastValue, render it with a visible "incomplete" marker, and put retry beside it. This is the right default for read-only panels where the user can judge whether the partial is useful. The invariants of an incremental parser are enough to tell which values are complete and which are still growing, and libraries exist specifically to extract a best-effort object from whatever has arrived per jsonchunk's examples. Best-effort parsers do the same trick, returning the partial structure rather than throwing on the missing brackets per the best-effort-json-parser readme.

Option C, repair and continue. Close the open braces heuristically and treat the result as complete. Only do this for display, never for anything that writes.

Whichever you choose, validate at the boundary. Truncated model output is untrusted input, and the expensive failure is not the crash, it is the half-parsed object that flows downstream and corrupts something three steps later as this comment on truncated responses argues. A schema check on the final object, and in-flight type checks on fields whose type is already decided, let you catch a type violation the moment it is determined rather than after the last token, which matters because a retry before the rest of the document is generated is cheap per the partial parser write-up.

Retry itself should be bounded and it should not silently reuse a partial. Move to connecting with attempt + 1, clear lastValue unless you are deliberately offering "keep what we had," and stop offering retry after a small fixed number of attempts so a persistent 4xx does not turn into a retry loop. Client errors are your bug and retrying them cannot help; transient server errors and rate limits benefit from backoff with jitter per this error handling guide.

One more rule that saves support tickets: when retry succeeds, replace rather than append. The panel that shows a repaired response stacked under a broken one is a UI bug people report as "the model gave me two answers."

Testing the machine, not the component

Four tests catch almost everything, and none of them need a browser.

  1. Every legal transition has a test, and every illegal one is asserted to be a no-op. Enumerate the state and event cross product, assert the reducer output, and snapshot the controls object alongside it.
  2. Fuzz the chunk boundaries. Take a corpus of valid responses, split each at every byte position, feed the chunks in order, and assert the final state is closed with a value deep-equal to JSON.parse on the whole string. This is the technique incremental parser authors use to claim their parser agrees with JSON.parse per the jsonriver discussion. Splitting at every position also catches mid-character string handling, which is where most hand-rolled parsers break.
  3. Simulate a stall. Send two chunks, advance fake timers past the watchdog threshold, assert stalled, then send a third chunk and assert the state returns to partial or receiving without user action.
  4. Simulate an abort at every state where cancel is legal. Assert no unhandled rejection, assert the panel does not flash to error, and assert the cancel button is disabled from the first click onward.

The value of running these in a reducer rather than through the component is that a failing test names the exact transition, and the fix is a one-line change to a table instead of a hunt through effects.

If you are building this against more than one provider, the transport layer is where the APIs differ and where a unified endpoint saves real work. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key, so the stream handling you write once keeps working when the model changes underneath it: https://quantaintelligence.ai/inference.

FAQ

FAQ

What if my response is small enough that partial rendering does not matter?

Keep the states anyway but collapse receiving and partial in the UI, not in the reducer. The reducer needs the distinction the day your prompt grows or a field arrives late, and adding a state to a reducer later means re-auditing every transition. The rendering can stay a single spinner as long as the machine underneath is honest.

Is a stalled stream the same as an error stream?

No, and treating it as one is why panels get stuck. A stalled stream may still deliver data, so cancel stays available and retry stays disabled. Only when you deliberately tear down the connection (abort plus reader cancel) do you transition to error and offer retry. If the stream recovers on its own, the panel should return to partial without the user doing anything.

Should cancel and retry ever be enabled at the same time?

Almost never. Cancel means "stop the thing that is running," retry means "start a new thing." The only case where both might be visible is a stalled stream where you show a disabled retry as a hint of what comes next; the actionable control should still be cancel. If you find both enabled, you probably need a cancelling state to cover the window between the click and the abort settling.

How do I know my parser and `JSON.parse` agree?

Split a corpus of valid documents at every byte offset and assert that feeding the chunks produces the same final value as parsing the whole string. That single property test covers mid-string truncation, escapes, nesting and unicode, and it is the same technique the published incremental parsers use to justify their correctness claims. Run it in CI against real responses you have captured.

Where does validation failure fit, since the stream finished successfully?

A stream that ends cleanly but produces an object your schema rejects belongs in error, not closed, because the user cannot act on the result. Validate the final object before the transition, keep the parsed value as lastValue so the panel can show what came back, and offer retry. Catching a type violation in flight is better still, since a retry before the document finishes is cheaper than one after.

Sources

Sources

  1. Streaming JSON Parsers: The Gap Between Tokens and ...
  2. JSON River, Parse JSON incrementally as it streams in
  3. Cancelling and exception handling async iteration of ReadableStreams
  4. Error Handling & Retries: Making LLM Calls Reliable
  5. How do I cancel an HTTP fetch() request? - Stack Overflow
  6. ReadableStream: cancel() method - Web APIs | MDN
  7. jbingen/jsonchunk: Parse partial JSON as it streams in. ...
  8. best-effort-json-parser
  9. Truncated LLM responses breaking JSON.parse is one ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles