Why Does an Incomplete Final Event in Streaming Break the UI?
Incomplete final events in streaming: how to preserve already received text when output is empty, deltas are missing, EOF occurs, or the status is incomplete.

A streamed response cannot be considered successful merely because the client read the connection to the end. In an LLM interface, this rule determines whether the useful text a person has already received stays visible or disappears because of one parser, proxy, or final-frame failure.
The most expensive mistake here looks harmless: the code keeps text only in a temporary buffer, waits for the final object, does not receive it, catches an exception, and replaces the entire response with “Something went wrong.” The user saw several paragraphs and may already have started reading them, but the interface erases them itself. That is not careful handling. It is data loss the developer could have prevented.
This article is about a text UI over SSE or a similar one-way stream. The same principle applies to WebSocket: transport termination, protocol parsing, and semantic response completion exist at different levels. As long as they are mixed into one finally branch, the bug will keep coming back.
Closing the connection does not mean the response succeeded
EOF says that reading bytes has ended. It does not say that the model finished generating, the gateway delivered all events, or the client saw the final status. For an interface, these are three different things.
The HTML standard for Server-Sent Events specifies an unpleasant detail: if a file ends in the middle of an event before a blank line, the client must discard the accumulated data for that unfinished event. The end of the stream does not itself trigger delivery of the last SSE frame. This matters when a proxy breaks the connection between data: and the blank line that separates events.
Imagine that the stream arrived like this:
event: response.output_text.delta
data: {"response_id":"r_42","seq":17,"delta":"Уже полученный текст"}
event: response.completed
data: {"response_id":"r_42","status":"completed"}
Here the client may move the response to completed only after parsing the second event. If the connection closes immediately after the first delta, the result is not “success” but “text received, outcome not confirmed.” If the connection breaks inside the second data: line, the browser's EventSource must not emit partially assembled JSON as an event. That is correct parser behavior, not a reason to delete the first delta.
OpenAI documentation for the Responses API distinguishes between response.completed and response.incomplete. In the second case, the response object receives the incomplete status and may contain incomplete_details.reason, such as max_tokens. This contract is useful not because every provider names events the same way, but because it demonstrates the right model: the final status belongs to the response, not to the TCP connection.
Make the rule explicit: only a recognized terminal event with status completed confirms successful completion. EOF, AbortError, a network timeout, a JSON parsing error, and a closed EventSource confirm only one thing: there are no more bytes.
Keep the text separate from the generation status
Text and response state should not live in one variable that the final handler either confirms or discards. Text has its own history, while the outcome has its own uncertainty.
I usually define these UI states:
connecting: the request was created, but no delta has been accepted;streaming: there is at least one valid delta, but the final outcome is unknown;completed: a valid terminal event with a successful status arrived;incomplete: a terminal event explicitly reports that the response is unfinished;interrupted: the stream ended or failed without a reliable terminal event;failed_before_output: the error happened before the first usable delta.
This is not bureaucracy in the client. The difference between incomplete and interrupted matters to both the user and the engineer. In the first case, the provider reported that the response stopped, for example because of a limit. In the second, you do not know whether the model stopped, the gateway failed, the network disappeared, or your code failed to parse the final event.
A minimal state record might look like this:
type StreamPhase =
| "connecting"
| "streaming"
| "completed"
| "incomplete"
| "interrupted"
| "failed_before_output";
type AnswerState = {
requestId: string;
attemptId: string;
phase: StreamPhase;
text: string;
receivedSeq: number;
terminalReason?: string;
parserError?: string;
};
The text field changes only when a text delta is accepted. The status handler has no right to reset it. It changes phase, saves the reason, and decides which actions to show beside the message.
This distinction is often blurred by saying “the response was not received.” That phrase is wrong in two different situations. “Not received in full” is not the same as “not received at all.” In the first case, the user already has part of the model's work. In the second, there is nothing to display. If you mix them, you damage both UX and metrics: completely empty failures become indistinguishable from interruptions after useful output.
Empty output requires a separate decision
An empty string, the absence of a text block, and the absence of events altogether are not the same thing. Handle each case separately, or the interface will start showing false errors or false successes.
Empty output with completed can be normal. The model may have returned only a tool call, a refusal, a structured item, audio, an image, or a service result that your rendering layer does not yet know how to display. In the Responses API, output is made up of typed items, while an SDK convenience such as output_text aggregates only text parts. An empty text field therefore does not prove that the response contains nothing.
Classify the content first, then decide what to display:
function classifyFinal(response: {
status: string;
output?: Array\u003c{ type: string; status?: string }\u003e;
}) {
const types = new Set((response.output ?? []).map(item =\u003e item.type));
if (response.status === "incomplete") return "incomplete";
if (response.status !== "completed") return "unexpected_terminal";
if (types.has("function_call")) return "tool_call";
if (types.has("refusal")) return "refusal";
if (types.has("message")) return "message";
return "empty_completed";
}
Do not replace empty_completed with “The model did not respond” until you have checked the item types. That message is useful only if your product truly expected text and agreed on that contract with the calling code. For an internal orchestrator, empty text after a tool call often means that the tool executor should run next, not the chat renderer.
If you support only a text scenario, state the rule honestly: “The response completed without text content.” Let the user retry the request, but do not call it a network error. A network failure and a valid empty response require different investigations.
A missing delta cannot be fixed by joining strings
When a phrase is missing from the text, teams often add deduplication such as if (!text.endsWith(delta)) text += delta. It is popular because it quickly removes visible repetitions after reconnecting. At the same time, it silently removes legitimate repetitions, breaks identical endings, and does not answer the question of whether a delta was lost.
Correct deduplication works from event identity, not event text. If the protocol provides a sequence number, use it. If it provides an event ID, save it. If it provides neither, your gateway should add a monotonic number at the boundary where it has already received the event from the provider.
Here is a handler with the expected behavior:
type DeltaEvent = {
type: "text.delta";
response_id: string;
seq: number;
delta: string;
};
function acceptDelta(state: AnswerState, event: DeltaEvent): AnswerState {
if (event.response_id !== state.requestId) return state;
if (event.seq \u003c= state.receivedSeq) return state;
if (event.seq \u003e state.receivedSeq + 1) {
return {
...state,
phase: "interrupted",
terminalReason: `gap_before_seq_${event.seq}`
};
}
return {
...state,
phase: "streaming",
receivedSeq: event.seq,
text: state.text + event.delta
};
}
This code does not try to guess the missing words. It records the gap and preserves the text already accepted. After detecting a gap, you can stop applying new deltas to this response, request a confirmed snapshot from the server, or show the user the partial result. The choice depends on the contract, but the gap must not be hidden.
There is an unpleasant case: the provider sends deltas without numbers, and your browser reconnects automatically. Native EventSource can reconnect, and the standard provides Last-Event-ID for continuing after a break. But this helps only if the server assigns IDs to events and can restore the sequence. MDN also shows that without an event field, messages arrive as ordinary message events, while the error handler is called for network problems. Do not mistake automatic reconnection for a guarantee that nothing was lost.
If there is a proxy between the browser and the LLM API, it is better to do one of two things. Either the proxy reads the upstream stream through the terminal event and gives the client its own numbered sequence. Or the proxy gives the attempt a unique ID, and after reconnecting the client requests the accumulated snapshot using that ID. The second option is easier to debug because the browser does not have to rebuild history from pieces delivered again.
A parser failure must not erase accepted events
A JSON parsing error often occurs not because the model returned bad JSON. It can be caused by an incorrect SSE frame boundary, reverse-proxy buffering, mixed data: lines, a gzip stream, ReadableStream cancellation, or a client that calls JSON.parse on every arbitrary network chunk.
Never treat a network chunk as an event. The HTTP layer may split one UTF-8 character across chunks, deliver several SSE messages in one chunk, or leave the last half of a frame in the buffer. Parse bytes incrementally through TextDecoder with stream: true, separate frames with a blank line, and only then parse the SSE fields.
A simplified example for a custom fetch client:
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body!) {
buffer += decoder.decode(chunk, { stream: true });
let boundary: number;
while ((boundary = buffer.indexOf("\\n\\n")) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const data = frame
.split(/\\r?\\n/)
.filter(line =\u003e line.startsWith("data:"))
.map(line =\u003e line.slice(5).trimStart())
.join("\\n");
if (!data || data === "[DONE]") continue;
try {
acceptProtocolEvent(JSON.parse(data));
} catch (error) {
markParserFailure({ frame, error: String(error) });
preserveVisibleText();
stopCurrentAttempt();
break;
}
}
}
This example does not replace a complete SSE parser. It deliberately shows the boundary of responsibility: frame first, JSON second. In production code, account for \\r\\n, comments, multiple data: lines, the event field, the id field, a buffer-size limit, and request cancellation. The SSE standard defines UTF-8, line-by-line processing, and a blank line as the signal to deliver an event.
The critical detail is in the catch handler. It must record the error, move the attempt to interrupted, and leave state.text unchanged. Do not call a general resetConversationMessage() there. A general reset is convenient until it makes the part of the response the person managed to see disappear.
Incomplete status is not a UI error
The incomplete status means that generation did not become a completed response under the provider's rules. It may be an output limit, cancellation, policy, internal stop, or another explicitly named reason. The interface must not disguise this status as “ready,” but it must not pretend that the received deltas never existed.
For ordinary chat text, use a simple policy:
- Leave all accepted deltas in place.
- Change the generation indicator to “response interrupted,” or to a more precise reason if it is safe and understandable.
- Show a “Continue” action only if the server can create a new attempt without repeating side effects.
- Show a “Retry” action if a new generation is allowed, but do not merge it with the previous one automatically.
- Keep the original attempt in history with its actual status.
“Interrupted” is better than “error” when text already exists. It does not promise that the response is correct or complete. At the same time, do not place a red banner over every interruption after 99 percent of the text. In a chat, a small status line below the message is enough. People should see the text, not be punished for an infrastructure problem.
The policy is stricter for structured results. If you expect JSON that follows a schema, do not pass an unfinished object to the consumer even when it happens to parse syntactically. A required field may be missing, an array may be unfinished, or a string may be cut off. For SQL, program code, tool arguments, legal forms, and medical instructions, apply the rule “full terminal success plus content validation.” Partial text may be saved in a log or shown as a draft, but it must not be executed.
Do not confuse incomplete with content filtering or a refusal. A refusal can be a fully delivered response with an explanation. An incomplete response can contain useful neutral text. These are different event classes in product analytics, or the team will try to fix generation limits with moderation settings and moderation with retries.
Validate the final event as strictly as a delta
Clients often validate every delta carefully but trust the final event based on a single type field. That is not enough. A terminal event must belong to the active response, arrive in an allowed order, and be consistent with what the client has already accepted.
A minimal check looks like this:
type TerminalEvent = {
type: "response.completed" | "response.incomplete" | "response.failed";
response: {
id: string;
status: "completed" | "incomplete" | "failed";
incomplete_details?: { reason?: string };
};
seq?: number;
};
function acceptTerminal(state: AnswerState, event: TerminalEvent): AnswerState {
if (event.response.id !== state.requestId) return state;
if (state.phase === "completed" || state.phase === "incomplete") return state;
if (event.seq !== undefined \u0026\u0026 event.seq \u003c state.receivedSeq) return state;
if (event.response.status === "completed") {
return { ...state, phase: "completed" };
}
if (event.response.status === "incomplete") {
return {
...state,
phase: "incomplete",
terminalReason: event.response.incomplete_details?.reason ?? "unknown"
};
}
return {
...state,
phase: state.text ? "interrupted" : "failed_before_output",
terminalReason: "provider_failed"
};
}
Do not allow a late completed event from an old attempt to close a new one. This can happen after cancellation, a model switch, or resubmitting a message. requestId must belong to one specific generation, while attemptId must distinguish a retry from the original user message.
There is also the reverse error: the frontend receives a terminal event, but the server aggregator later overwrites it with a timeout. A terminal state must be irreversible. After completed and incomplete, do not move the record to interrupted because a socket-close event arrived later. The socket must close after a normal end, and that is not a new business error.
A retry can create two responses instead of one
Automatic retry seems safe when generation is interrupted. It is safe only for pure generation, where a retry does not run tools, create a CRM record, send an email, or change an external system.
If the response could call a tool, first find out at which stage the attempt stopped. There are four different outcomes:
- the tool was not requested;
- the model requested the tool, but the executor did not start;
- the executor finished, but the result did not reach the model;
- the model received the result and started writing text to the user.
You cannot retry the same request in the second and third cases without an idempotency key on the tool side. Otherwise, “create an invoice” becomes two invoices. In a streaming architecture, this happens more often than it seems: the UI sees an interruption and sends a retry, while the server-side task is still running and manages to complete the original operation.
To continue text, do not ask the model to “write the response again” and do not merge two generations without marking the boundary. Give it the confirmed visible fragment and a narrow instruction: continue after the last sentence and do not repeat the text already shown. Then display the new part as a separate block until the server confirms that it belongs to the same logical chain.
If your architecture uses a single OpenAI-compatible endpoint, AI Router can let you keep your existing SDK and stream handler without rewriting the call. But the semantics of completed, incomplete, and transport failure must still be defined by your client contract, not by changing base_url.
A checklist for client behavior after a bad final event
Keep this checklist next to the rendering code and turn it into tests. It defines not the look of an error, but the preservation of state.
When text already exists
- Add every valid delta to the message's persistent state immediately.
- On EOF without a terminal event, keep the text and set
interrupted. - On
response.incomplete, keep the text and record the reason. - On a parser error after an accepted delta, stop the attempt but do not clear the text.
- On a sequence-number gap, record the break and do not present the response as finished.
When there is no text
- For
completedwithout text items, check the output types instead of replacing the result with a network error. - For
incompletebefore the first delta, show that generation stopped before producing output. - For a parser error before the first delta, show a technical error and a retry button.
- On user cancellation, keep the
cancelledstatus if the protocol provides it, and do not call it a model failure. - For an unknown terminal event, save the raw type name for diagnostics and do not mark the response as completed.
Check that the UI does not replace the entire message when moving between these states. In React and similar systems, this means keeping a stable message ID and updating individual fields. If, on interrupted, you create a new error bubble instead of updating the current assistant message, the user will see two contradictory objects: text without a status and an error without text.
Test the stream as an event log, not as a string
A test that sends “Hello” and then completed checks almost nothing. It does not catch the failures that make an interface lose data on a real network.
Build fixtures from event sequences. Each fixture should check the final text, phase, reason, and available actions. At minimum, these scenarios are useful:
const cases = [
{
name: "нормальное завершение",
events: [delta(1, "Первый абзац."), done(2)],
expect: { text: "Первый абзац.", phase: "completed" }
},
{
name: "обрыв после текста",
events: [delta(1, "Первый абзац."), eof()],
expect: { text: "Первый абзац.", phase: "interrupted" }
},
{
name: "incomplete после текста",
events: [delta(1, "Первый абзац."), incomplete(2, "max_tokens")],
expect: { text: "Первый абзац.", phase: "incomplete" }
},
{
name: "разрыв последовательности",
events: [delta(1, "Первая часть "), delta(3, "третья часть")],
expect: { text: "Первая часть ", phase: "interrupted" }
},
{
name: "незакрытый SSE кадр",
events: [raw("data: {\\\\\\"type\\\\\\":\\\\\\"text.delta\\\\\\""), eof()],
expect: { text: "", phase: "failed_before_output" }
}
];
The last fixture is particularly useful for testing a custom SSE parser. Under the standard, this frame must not reach the event handler. If your test receives partial JSON, you are parsing a convenient demo substring rather than SSE.
Test cancellation during active generation separately. After the user cancels, late deltas and terminal events from the old attempt must not change the new message. Check this not with timers but by deliberately delivering events in the wrong order.
In observability data, record the response ID, attempt ID, terminal event type, number of the last delta, incomplete reason, character count, time to first delta, and time from the last delta to the end of the stream. Do not send full prompts and outputs to technical logs by default. For banking, healthcare, and other regulated scenarios, an unmasked log quickly violates your own data-retention rules.
Add one more metric: the share of interrupted responses with non-empty text. It shows user impact better than the overall HTTP error rate. Another useful breakdown, the share of completed responses with unexpectedly empty text, quickly reveals a broken output-type router or a new provider response format.
A streaming UI does not have to promise users that every response will finish. It does have to avoid discarding confirmed data because the final confirmation did not arrive. Keep deltas separate, require an explicit terminal status for success, and treat incompleteness as a message state. Then one bad frame will not turn several good paragraphs into an empty space.
Frequently asked questions
Should the text be cleared if the stream closes without an HTTP error?
Because a closed HTTP connection reports only what happened at the transport level. It does not prove that the provider sent a final domain status or that the client received and parsed it. If the UI clears the buffer whenever reading ends, it destroys valid deltas itself.
Can you show users text from an incomplete response?
Yes, if the delta has already passed validation and is tied to the current request. Keep it as a draft result, show a clear status, and let the user continue, retry, or copy the text. The exception is content that cannot safely be shown partially, such as an unfinished money transfer command.
What does an empty output mean in an LLM streaming response?
An empty output does not mean an error by itself. The model may have called a tool, returned a refusal, sent a service event, or failed to produce its first text delta. Treat missing text as a separate state, not as an empty string that must always be displayed in the chat.
How can you tell that a delta was missed in the stream?
A missing delta often looks like incomplete text, but the cause may be a reconnect, poor-key deduplication, a decoder error, or a proxy splitting an SSE frame. You need a sequence number, a response ID, and a log of accepted events. The cause cannot be determined from the text alone.
Does EOF count as successful completion of an SSE stream?
No. SSE defines how events are packaged, but it does not define which event means successful completion for a particular LLM protocol. The client should wait for an explicit terminal event or a final object with status completed, if that is what the provider contract specifies.
Can an incomplete stream be retried automatically?
Retry only an idempotent action and only when you have a clear way to link the new attempt to the old one. For text generation, offer to continue from that point or start a new response with the received fragment in context. Automatic retries can easily create duplicates, two tool operations, or unnecessary costs.
How should the interface display incomplete status?
For ordinary text, show a label such as “response interrupted” beside the saved fragment. For JSON, SQL, code, tool arguments, and any executable data, a partial result is not ready. Keep it for diagnostics, but do not pass it onward without a separate validation step.
Should LLM stream events be deduplicated?
Yes, but limit deduplication by time, request ID, and sequence number. Repeated delivery is acceptable, while blindly joining identical strings is not. The state should accept events from one active response and ignore anything that arrives after cancellation or belongs to an earlier attempt.
What metrics and logs are needed for incomplete streams?
In most cases, response ID, attempt ID, sequence number, event type, the time of the first and last delta, the length of the saved text, the final status, and the incomplete reason are enough. Do not put the full user prompt and response in ordinary logs without masking rules. They quickly become a store of personal data.
What tests are needed for a streaming response client?
Test five cases: normal completed, empty output with completed, deltas without a terminal event, terminal incomplete after several deltas, and a break in the middle of an SSE frame. The last case is especially important: under the SSE standard, the browser must not deliver an unfinished frame as an event. Test both the browser path and the server proxy if you have one.