A chat interface should not have to wait for the entire answer before displaying anything. Microsoft Foundry Responses can deliver incremental output, but connecting that stream to React involves more than appending each network chunk to a message.
A chunk might contain half a JSON event, several events, or part of a UTF-8 character. Meanwhile, a user can cancel the request or sign out before an awaited read returns. The frontend needs to reconstruct events and decide whether they still belong to the active request.
The practical change is a small transport hook between an authenticated backend and the React chat state. It sends a streaming request, parses complete events, and forwards only current output. This article focuses on that first streaming slice, not conversation history or mutation recovery.
What supplies the stream
In this banking-assistant prototype, Microsoft Agent Framework defines the agent workflow. A Responses host based on ResponsesHostServer exposes it through POST /responses. Microsoft's Agent Framework hosting guide describes the same protocol for local testing and Foundry hosted agents: setting stream to true produces events such as response.output_text.delta and response.completed.
The browser calls the application's Backend for Frontend (BFF), not the agent directly. The BFF checks the application identity and forwards the Responses stream. Foundry credentials remain server-side; the browser supplies its application bearer token.
This project extends Microsoft's public banking-assistant sample. Its dedicated Identity service, BFF, and React frontend are project-specific extensions, not features supplied by the sample or automatic Foundry guarantees.

Figure 1. The stream becomes React output only after event reconstruction and a current-request check; stale output is suppressed.
The examples assume an existing authenticated BFF endpoint that returns text/event-stream, a React application, and browser Fetch and Streams APIs. The referenced project uses React 18; its frontend manifest records the dependency ranges. These excerpts do not provision Foundry, implement login, or configure deployment. Real agent requests incur the applicable model and hosting costs; the offline checks below make none.
Use Fetch for the authenticated POST
Native EventSource is convenient for URL-based subscriptions, but this request needs a JSON POST body and an explicit Authorization header. Fetch exposes the response body as a readable byte stream while allowing both.
The following is partial React wiring, adapted for explanation. The surrounding component supplies the BFF URL, input, current-token getter, state setters, and an AbortController. isCurrent() checks that the controller has not been aborted and the application token still matches the one captured when the request started.
const response = await fetch(bffUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${tokenAtStart}`,
},
body: JSON.stringify({ input, stream: true }),
signal: controller.signal,
});
if (!isCurrent()) return;
if (!response.ok) throw new Error("Response unavailable");
await readResponseEvents(response, isCurrent, (event) => {
if (
event.type === "response.output_text.delta" &&
typeof event.delta === "string"
) {
const delta = event.delta;
setAnswer((previous) => previous + delta);
}
});
Functional state updates append each delta to the latest React state rather than a value captured before the stream started. This reduced example displays one answer. The actual chat provider also uses output item identifiers to keep messages separate and handles non-text events.
Production HTTP failures should map to controlled application error codes. The real hook does this instead of displaying raw server details.
Decode bytes before parsing events
Server-sent events (SSE) are text records, not network chunks. Blank lines delimit records; consecutive data: lines form one payload. The SSE format reference describes those boundaries.
This standalone parser extracts the relevant mechanism from the project's streaming hook. It is a reduced reader for this endpoint's JSON events, not a complete general-purpose SSE client.
interface ResponseEvent {
type: string;
delta?: unknown;
}
async function readResponseEvents(
response: Response,
isCurrent: () => boolean,
onEvent: (event: ResponseEvent) => void,
): Promise<void> {
if (!response.body) throw new Error("Unreadable response");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let dataLines: string[] = [];
const emitEvent = (): void => {
if (!dataLines.length) return;
const data = dataLines.join("\n");
dataLines = [];
if (data === "[DONE]") return;
const event: unknown = JSON.parse(data);
if (
!event ||
typeof event !== "object" ||
!("type" in event) ||
typeof event.type !== "string"
) {
throw new Error("Invalid stream event");
}
if (isCurrent()) onEvent(event as ResponseEvent);
};
const emitLine = (line: string): void => {
const normalized = line.replace(/\r$/, "");
if (!normalized) emitEvent();
else if (normalized.startsWith("data:")) {
dataLines.push(normalized.slice(5).replace(/^ /, ""));
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (!isCurrent()) return;
if (done) {
buffer += decoder.decode();
if (buffer) emitLine(buffer);
emitEvent();
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!isCurrent()) return;
emitLine(line);
}
}
} finally {
reader.releaseLock();
}
}
Three buffers have different jobs: TextDecoder preserves incomplete UTF-8 characters, buffer preserves incomplete lines, and dataLines preserves an event until its separator arrives. Calling JSON.parse directly on every chunk would skip those boundaries.
The reader accepts LF and CRLF framing and ignores metadata lines and [DONE]. Like the project hook, it also flushes a final payload at EOF without a terminating blank line. That is an explicit endpoint-compatibility choice, not a promise to reproduce native EventSource behavior. It does not implement reconnection, id: persistence, or every SSE framing variant.
Keep cancellation separate from completion
The owning hook creates an AbortController per request. React effect cleanup aborts that controller when the request changes or the component unmounts; the Cancel action aborts it too. Callback refs let the transport call the latest handlers without restarting the request merely because a handler changed.
Aborting is only half the protection. A pending promise can settle after cancellation. Checking isCurrent() after awaited operations and before dispatch prevents old text or errors from reaching the replacement chat. Comparing the captured token also suppresses callbacks after a login change. It is a frontend lifecycle guard, not a substitute for server authorization.
The owning hook catches parsing and transport failures, aborts the request, and reports controlled text. Intentional cancellation must not become a raw exception displayed to the user.
Stream closure is not successful response completion. The chat provider tracks response.completed separately from failed or incomplete outcomes, then ends its loading state when the transport finishes. A visible text delta is not proof that the turn succeeded.
Likewise, aborting the browser request does not prove that the server stopped a tool operation or rolled back an effect. This client does not automatically reconnect and resend the POST. Safe business-operation recovery is a separate contract.
What the offline checks establish
The focused streaming-hook selection passed all 21 tests:
npm --prefix app\frontend\banking-web run test -- src\components\chat\useThreadStream.test.ts
The test source checks fragmented UTF-8, metadata, malformed payloads, current output, and late callbacks after cancellation or token changes. It mocks React hooks and Fetch; it is not a browser rendering test.
The extracted parser above was also checked with synthetic byte streams, including byte-by-byte UTF-8 fragmentation, multiline data, malformed JSON, and stale-request suppression. These are deterministic transport checks, not captured model answers or hosted runtime results.
Before adopting the pattern, verify actual browser cancellation and rendering, authentication expiry, and stream delivery through the deployed proxy. A parser test cannot establish that infrastructure avoids buffering or that cancellation reaches upstream execution.
The transferable pattern
Keep byte decoding, event framing, and chat state updates separate. Send the authenticated POST through the application boundary, and treat every awaited result as potentially stale. Most importantly, distinguish partial output, response completion, and business-operation completion: each requires its own evidence.
The tradeoff is ownership of parsing and lifecycle code rather than delegating subscription behavior to EventSource. Focused regressions make that responsibility explicit without claiming end-to-end acceptance.
Implementation and references
Join the conversation! Your thoughts help the community grow.