Streaming sends partial output before a response is complete. It can improve perceived responsiveness, but it introduces partial states and interruption behavior. The application must distinguish an incomplete stream from a committed result.
Associate events with a request or turn ID so late output cannot replace a newer request. Cancel unnecessary work when the user leaves or interrupts, while recognizing that a completed external action cannot be undone by closing a connection. Reconnection should recover known state rather than blindly repeat a mutation.
A session identifies related interaction, not unlimited authority. Recheck permissions for protected actions and scope conversation history to the correct user. Keep transport details separate from durable task state.
Exercise: simulate a stream that stops halfway, then starts a second request. Deliver late events from the first request and verify they are ignored by the current UI.
Check: partial output is visibly incomplete and a reconnect cannot duplicate an already committed action.
Guided workshop: follow a request through the full stack
A useful AI page is an ordinary application around a model call. The browser collects a question, the backend authenticates the user, retrieval selects authorized evidence, and the model produces a candidate answer. The backend validates the result before the browser renders it. Provider secrets stay on the backend.
Write down this contract before implementing a UI:
{"request_id":"r-7","status":"complete","answer":"Open Settings.","source_ids":["guide-1"]}
A browser request ID identifies a presentation attempt. A server operation ID identifies work and side effects. They may be related, but a cancelled browser request does not prove that remote work stopped.
Build the client: use a labelled question input, a submit button, a visible loading state, and an aria-live="polite" status area. Keep errors next to the form and preserve the typed question. First use a fake backend with configurable delays; no model is needed to test the interaction.
Use the following control-flow sketch when implementing your client:
let generation = 0;
let pending;
async function ask(question) {
const ownGeneration = ++generation;
pending?.abort();
pending = new AbortController();
try {
const response = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
signal: pending.signal,
});
if (!response.ok) throw new Error('Request failed');
const result = await response.json();
if (ownGeneration !== generation) return;
// Validate result, then render it as text in the current answer region.
} catch (error) {
if (ownGeneration !== generation) return;
// Present cancellation or failure; never insert an error as raw HTML.
}
}
This is client control flow, not a supplied API endpoint. Cancellation limits wasted work where supported; the generation check prevents a late result from replacing a newer answer even if cancellation was ineffective.
Streaming extension: define typed events such as delta, citation, complete, and error. Display deltas as provisional text. A disconnected stream is not a completed answer, and a partially emitted action proposal is not approved. Preserve an explicit terminal event and discard events for an older generation.
Verification: submit A, immediately submit B, and force A to finish last. Only B should own the current result. Then simulate 401, 429, timeout, malformed JSON, and stream disconnection. Record expected UI states. The user must be able to retry without refreshing and without accidentally duplicating a write.