Georgii EmelianovEngineering

Streaming UI, atomic commit: the user never sees a half-built screen

Every published fix for streaming instability is about how the screen looks while it fills in. None of them stops the user tapping a comparison that is still missing one of its two sides.

Torn white, cool grey-violet, and black paper framing the headline "Streaming UI, atomic commit: the user never sees a half-built screen".

The screen fills in from the top. The heading lands, then a callout, then the first half of a comparison. The user — who asked a question and is waiting for the answer — taps the thing that has already appeared.

Everyone building this has shipped that once. The published fixes for it are all about how the screen looks while it fills in: layout shift, reconciler churn, scroll jumping. A streaming UI atomic commit is a different property, borrowed from a different discipline, and it is about what the screen is allowed to do before it is finished.

The short version

An atomic commit means a generated screen has exactly two states a user can act on: the one before this turn, and the one after it completes. Draft content renders and scrolls but does not respond to taps. A terminal record makes it interactive. Anything else — an invalid record, a dropped connection, a cancelled request — restores the screen the user already had.

Smooth is not the same as safe

The current advice on streaming interfaces is good and it is solving the neighbouring problem.

Joas Pambou's "Designing Stable Interfaces For Streaming Content" (Smashing Magazine, 1 May 2026, checked 19 August 2026) is the best of it. Auto-scroll only while the user is already at the bottom. Update changed content rather than rebuilding the DOM. Batch updates with requestAnimationFrame, use aria-live, respect prefers-reduced-motion. The Prompt Bench's streaming patterns adds atomic rendering of block-level elements, and stable keys so the reconciler does not discard finished blocks.

None of it gates interaction, and that is a deliberate position rather than an oversight — the Smashing piece prioritises user agency throughout, and for streamed prose it is right. Its recommendation for an interrupted stream follows from the same logic: clear the buffers, remove the cursor, mark the response incomplete, offer retry.

An explainer on the same SERP states the residual problem without answering it: parts of the UI may be incomplete while the user is already interacting with it.

That gap is where the two mediums separate. A truncated paragraph keeps most of its value; the reader can see it stopped, and "incomplete, retry?" is honest. A truncated screen does not announce itself. A comparison with one of its two subjects missing is a comparison. A metric row with two of its four figures is a metric row. It looks finished, because looking finished is what structure does, and the user acts on it.

Borrow the word from transactions, because it is the same idea

Search this phrase and page one still returns the database definition and React's render-and-commit phases. Nobody in generative UI has claimed the term, which is a shame, because the mapping is exact.

BEGIN is the first record of the turn. It installs the new screen and captures what was there before.

COMMIT is the terminal record. It marks the screen finished, makes it interactive, and discards the saved previous state.

ROLLBACK is everything else, and it puts the previous screen back.

The grammar makes the terminal record actually terminal rather than conventionally terminal. In the shipped stream grammar the committed state has an empty transition table — it accepts no record type at all — and the constraint list carries "recordsAfterCommit": 0. A stream that ends without reaching it is an error rather than a shorter screen: the server's own finish path raises "Presentation NDJSON ended before screen_commit."

The undo image is taken at the start, not reconstructed at the end

This is the part that is easy to get wrong, because the failure and the fix are at opposite ends of the turn. By the time a stream dies, the previous screen has already been overwritten by the one that was filling in. There is nothing left to restore unless you saved it on the way in.

So the save happens when the first record lands:

private struct ProgressiveScreenBackup {
    let screenId: String
    let index: Int
    let previous: ScreenState?
}

// Taken when the turn's first record installs the new screen.
switch navigation {
case .push:
    // Nothing occupied this index before, so rollback means "remove it".
    backup = .init(screenId: screen.screenId, index: screens.count, previous: nil)
    screens.append(screen)
case .replace:
    let index = screens.count - 1
    backup = .init(screenId: screen.screenId, index: index, previous: screens[index])
    screens[index] = screen
}

Two navigation modes, two shapes of undo. A push adds a screen, so rolling back removes it. A replace overwrites one, so rolling back needs the overwritten value held for the duration of the turn. previous: nil is not an absent backup — it is the recorded fact that there was nothing there.

On commit, the backup is released. That release is the commit: after it, there is no way back, which is exactly the property you want, and it is one line rather than a subsystem.

Draft content renders; it does not respond

The gate is a single expression, evaluated where the view is built:

ScreenView(
    screen: screen,
    interactionsEnabled: screen.isCommitted && !session.isStreaming
)

Both conditions earn their place. isCommitted is the transaction state: the terminal record has arrived. !isStreaming covers the turn as a whole, so a committed screen still does not accept taps while the session is mid-turn for any other reason.

What the user gets during the draft phase is a screen they can read and scroll — the whole point of streaming — with no control that does anything. The distinction is worth being precise about with your designers, because "disabled" is not the right treatment. The content is not disabled; it is not yet interactive, and it should look like content, not like a form waiting on a network call. On a model-composed screen the draft phase is usually under a couple of seconds, and the correct visual answer is almost always "nothing" — no spinner overlay, no dimming, just a screen whose buttons have not appeared yet.

Four failures, one cleanup path

There are four ways a turn can fail to produce a screen, and they arrive through four different mechanisms:

  1. The server aborts explicitly. An abort record reaches the client with a reason.
  2. A record fails validation on the device. The client's own reducer rejects it.
  3. The transport dies. The connection drops mid-stream.
  4. The turn is cancelled. The user navigates away, or the task is cancelled.

All four have the same correct response, and the design decision is to give them literally the same one. Each path calls the same restore: put previous back at index, or remove the screen that was appended, then clear the backup and end the turn.

The abort record is not required for correctness. It carries a reason for logging; the restore it triggers is the same restore a dropped connection triggers. If it never arrives, nothing is left behind.

That is the test worth running against your own implementation: unplug the network mid-stream and see whether you get the same end state as a clean abort. If the two differ, one of them is the path nobody tested.

It matters that the abort record is server-only — the grammar lists it under serverOnlyRecords, so it is not in the set of records the model can emit. A model that could emit an abort could roll back a screen it had already been made to produce, and rollback would become something the content could trigger. It is one of the constraints in what a generated screen is structurally unable to do. The instinct is the same one behind rejecting invalid output rather than repairing it: the failure path belongs to the host.

What this still does not protect you from

A committed screen can still be wrong. Atomicity is about completeness, not correctness. A screen that assembled every record in the right order and passed every check can still compare the wrong two things.

Rollback restores a screen, not attention. The user watched something appear and then vanish. They need to be told, in a sentence, that the answer did not arrive — restoring the previous state silently is its own kind of confusing.

And there is no partial credit. This is the real cost and it is not small. A stream that dies at ninety per cent shows the user nothing new. For a long screen on a poor connection, "keep what arrived and mark it incomplete" genuinely serves the user better, which is why the prose-streaming advice says so. The trade is worth taking when the content is structured and acted on, and it is worth not taking when the content is text someone reads. Know which one you are building.

Frequently asked questions

Should a streaming UI be interactive before it finishes?

If the content is prose, yes — a partially streamed paragraph is readable and the user loses nothing by being able to scroll, select and copy it. If the content is structure the user will act on, no. A half-arrived comparison or metric row looks finished, so the user cannot tell that acting on it is premature, and only the app knows.

What should happen if a streamed response is cut off halfway?

For text, mark it incomplete and offer a retry — that is the standard advice and it is right. For a generated screen, restore what the user had before the turn and say the answer did not arrive. The distinction is whether a truncated result announces its own truncation; text does, structure does not.

How do you stop a user tapping a half-rendered screen?

Gate interaction on an explicit completion flag rather than on a heuristic like "the last chunk was a while ago". Draft content should render and scroll normally with no active controls, and become interactive when the terminal record lands. Disabling controls visually is a worse answer than not having drawn them yet.

Is atomic commit a database term or a UI one?

It is a database term, and the properties transfer directly. A turn is a transaction, the first record begins it, the terminal record commits it, and any failure rolls back to the state before it started. What does not transfer is durability — there is nothing to persist here, only a screen to restore.

Where to start

Take your streaming surface and answer one question: what is on screen, and tappable, one second before the stream ends? If the answer is "part of the next screen", you have a state your product spec does not describe.

Then unplug the network halfway through and compare the result to a clean abort. Those two paths agreeing is most of the work.

If you would rather read a version that already does this, the shipped stream grammar and reducer are what we would show you first. An undo image taken at the first record, a terminal record that commits, one restore path for four failures. Email hello@uzori.ai and ask for them.

← All posts