Hands-On Practice
From the course AI Streaming & Real-Time Applications
The project
This lesson replaces the usual list of ideas with one concrete project: a streaming chat client that you can test without calling any model. The trick is to build against a recording. A recording never fails on its own, never costs anything, and can be replayed a hundred times with different timing, which makes it the best way to find parsing bugs and to judge perceived latency honestly. You connect a live model only at the end, when the client has already survived everything you could think of.
You will use the techniques from all four earlier lessons. Streaming transport and event format come from lesson 1, incremental rendering from lesson 2, retry and cancellation from lesson 3, and the relay mindset from lesson 4. Keep the earlier lessons open while you work.
Step 1: Make a recording
Create a file called recording.txt with a short stream in your own simple protocol. Use blocks separated by blank lines, a data: line for the payload, and an event: line for the blocks that are not plain text. Include a handful of ordinary text events, one ping, one event of a type your client has never heard of, one error and a final stop. An invented recording like the one below is enough.
data: {"text":"Hello"}
data: {"text":", world"}
event: ping
data: {}
event: mystery
data: {"anything":true}
data: {"text":"!"}
event: stop
data: {}
The mystery event is deliberate. A vendor might add a new event kind someday, and a client that crashes on an unfamiliar type would break on that day.
Step 2: Parse it and test it by replay
Write the parser as a pure function that takes text and returns events, and write a replay helper that feeds it the recording in slices. A pure function is easy to test, because it needs no network, no timers and no page.
export type StreamEvent = { event: string; data: string };
export class EventParser {
private buffer = "";
push(chunk: string): StreamEvent[] {
this.buffer += chunk;
const out: StreamEvent[] = [];
let cut = this.buffer.search(/\r\n\r\n|\n\n|\r\r/);
while (cut !== -1) {
const block = this.buffer.slice(0, cut);
const rest = this.buffer.slice(cut).replace(/^(\r\n\r\n|\n\n|\r\r)/, "");
this.buffer = rest;
out.push(parseBlock(block));
cut = this.buffer.search(/\r\n\r\n|\n\n|\r\r/);
}
return out;
}
}
function parseBlock(block: string): StreamEvent {
let event = "message";
const data: string[] = [];
for (const line of block.split(/\r\n|\n|\r/)) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
if (line.startsWith("data:")) data.push(line.slice(5).trim());
}
return { event, data: data.join("\n") };
}
export function replay(recording: string, sizes: number[]): StreamEvent[] {
const parser = new EventParser();
const events: StreamEvent[] = [];
let at = 0;
let i = 0;
while (at < recording.length) {
const size = sizes[i % sizes.length];
events.push(...parser.push(recording.slice(at, at + size)));
at += size;
i += 1;
}
return events;
}
Now write the test: replay the same recording with sizes such as one character, seven characters and a random mixture, and compare the three results. They must be identical. If they are not, your buffer cuts an event in the wrong place, and the fix belongs in the parser, not in the test.
Step 3: Render with one write per frame
Feed text events into the batching class from lesson 2 and ignore everything else. Count the number of page writes during a replay and show the count in a debug label. If the count equals the number of text events, your batching is not working, because several events should share a frame.
Step 4: Cover the edge cases
A good practice client is one that has met the awkward events before a customer does. Add a recording case for each of the following, taken from the vendor guides, and each block says which guide it comes from. Start with the chat completion format. The next two sentences come from the OpenAI guide.
When you stream a chat completion, the response carries a delta field rather than a message field.
The next fact tells you what that field may contain.
The delta field can hold a role token, a content token, or nothing.
Next, add a case for events that carry no text. The next two sentences come from the Anthropic guide.
Event streams might also include any number of ping events.
The API might occasionally send errors in the event stream.
Write the error case so that the text already on screen stays visible and a notice appears below it. Then turn to the Anthropic guide's thinking events, which add blocks that your renderer must not mistake for the answer.
With thinking and streaming enabled, you receive thinking content through thinking_delta events.
The blocks have their own field.
These deltas correspond to the thinking field of the thinking content block.
There is also a closing event for such a block.
For thinking content, a special signature_delta event is sent right before the content_block_stop event.
The purpose of that event is stated separately.
This signature is used to verify the integrity of a thinking block.
You do not need to verify anything in this project; the point is that the signature event arrives and must be skipped without breaking the block. One more case follows from a setting that you control.
When display: "omitted" is set in the thinking configuration, no thinking text is streamed.
So your renderer should accept a thinking block that never produces visible text. Finally, add a case for a tool call, where the pace changes. The next two sentences also come from the Anthropic guide.
Current models only support emitting a single complete key and value property from input at a time.
The consequence for the reader follows directly.
As such, when you use tools, there might be delays between the streaming events while the model is working.
Show a status line for those pauses instead of a frozen caret. If you want a deeper dive into partial tool input, return to lesson 2.
Step 5: Add cancel, retry and policy
Add a stop button that aborts the request and cancels the retry timer, as in lesson 3. Add a retry button that reappears after an error. Then decide what your client does with unfinished text when the reader presses stop: keep it, mark it as incomplete, or discard it. Moderation is part of this decision. The next sentence comes from the OpenAI guide.
Partial completions may be more difficult to evaluate.
A second wrinkle concerns services that score content. This sentence is from the OpenAI guide too.
The scores arrive after the full generated output is available, if you request moderation scores with a generation request.
Step 6: Connect a live model last
When the replay tests pass, point the client at a real model through your own server. The earlier lessons showed the relay. The streaming switch itself is small. The next two sentences come from the OpenAI guide.
To start streaming, set stream=True in your request to the Responses endpoint.
To stream completions, set stream=True when you call the Chat Completions or legacy Completions endpoints.
Server-side SDKs offer their own helpers. The next two sentences come from the Anthropic guide, so they describe its SDKs only.
The Python SDK allows both sync and async streams.
SDK differences are real, so do not assume the next point carries over from one language to another.
See the documentation in each SDK for details.
Run your replay tests again against a recording captured from the live model. Save a few real recordings in your project folder, because they make the best regression tests you will ever have.
Review checklist
Before you call the project finished, answer these questions in writing. Which events does the client render, which does it skip, and which does it reject? What happens when the connection drops in the middle of a word? What does the reader see when the stream never starts? Could you explain the design to a teammate in two minutes?
Stretch goals
Add a second recording that interleaves two content blocks and make the renderer handle both. Add a latency readout that shows the time to first output. Write a short note comparing your hand-written parser with the helper offered by the SDK you chose, and list what each approach does better.
Key Takeaways
- •Test a stream parser by replaying one recording in many chunk sizes.
- •Give every unusual event type a deliberate case in your renderer, even if the case is to skip it.
- •Build the client against a recording first, and connect a live model last.
- •Cancel, retry and stop signals belong in the first version of the project, not the last.
Progress is kept in this browser only.