Faking latency and failure honestly
A mock that returns instantly and always succeeds lets you skip every decision the real thing forces — token cadence, tool-call pauses and a failure halfway through a run are the three behaviours that change what you design, so a fake that omits them is designing a different product.
Your mock streams. It answers in four milliseconds, renders in one frame, and never fails. You demo it, and something is off in a way nobody in the room can name. The spinner never appears long enough to read. The approval step arrives before anyone has finished the sentence it interrupts. Nobody asks what happens when it breaks, because nothing in the demo suggested it could.
A prototype is a claim about what the real thing will feel like. A mock that returns instantly and always succeeds makes that claim falsely, and the falseness is invisible, because everything on screen looks correct.
Cadence is scripted, not configured
The first instinct is to reach for a delay helper and expect it to pace the stream. It will not, and being precise about why saves you an hour.
Mock Service Worker ships a delay() function with two overloads: delay(duration?: number) and delay(mode?: 'real' | 'infinite'). Called with no argument at all, MSW’s own documentation says it applies “a realistic server response time” — a random number equal to the average response time you encounter when communicating with an actual HTTP server, which the page puts at roughly 100 to 400 milliseconds. Two things about that are easy to get wrong.
- It delays one response, not a stream of chunks. The helper waits, then the resolver returns. Nothing about it spaces out the tokens inside a streamed body.
- The implicit version is switched off inside Node test runs — MSW suppresses it there, in its own words, to prevent it affecting test performance. So an argument-free
delay()is slow in your dev server and instant in your tests. If you want the same behaviour in both, pass a duration or a mode explicitly.
Cadence across a stream is therefore something you write. MSW’s streaming documentation builds a response body from a ReadableStream, and the spacing between chunks is whatever gap you put between the enqueue calls:
import { http, HttpResponse, delay } from 'msw'
export const handlers = [
http.post('/api/mock-agent', async () => {
// Explicit, because the argument-free form is suppressed in Node tests.
await delay(1200)
return HttpResponse.json({ ok: true })
})
]
// Cadence is the gap you write, not a setting you flip.
const stream = new ReadableStream({
async start(controller) {
for (const token of tokens) {
controller.enqueue(new TextEncoder().encode(token))
await new Promise((resolve) => setTimeout(resolve, 35))
}
controller.close()
}
})If you would rather not hand-roll the event framing, MSW also has an sse namespace — sse(path, ({ client }) => client.send(...)) — which the docs position as abstracting away stream management and message encoding. The pacing is still yours. Nothing in either API knows what a plausible token rate looks like, and nothing should: the rate you pick is a design decision about the surface, not a fact about a model.
A tool-call pause is a gap between two parts
This one needs no library at all, which is why it is the cheapest of the three to fake and the one most often skipped. The protocol already separates the call from its result. Arguments arrive as tool-input-start, tool-input-delta and tool-input-available. The result arrives later as tool-output-available, or as tool-output-denied if it was refused. The AI SDK’s own tool-calling documentation describes that lifecycle, and notes that the input-delta callback fires only in streaming contexts — Vercel documenting its own SDK, as always in this course.
The pause is the wait you insert between those two parts. That is the whole mechanism. What it buys you is the question a demo audience will ask and a static mock cannot answer: what is on screen while a tool is running, and can the person cancel it.
Failure has to arrive mid-run, not at the door
A mock that returns a 500 before streaming anything tests the error boundary you already have. The interesting failure is the one that arrives after the surface has committed: half a paragraph rendered, a tool call in flight, and then nothing more.
There are three honest ways to produce it.
- Emit the protocol’s own
errorpart after severaltext-deltaparts have already landed. This is the faithful one, because it is what a real backend does. - Error the stream itself —
controller.error(...)part-way through — which models a transport that died rather than a run that reported failure. The surface sees these differently and should. - Return an error response from the handler, which MSW supports like any other response, for the pre-stream case. Keep it, but do not let it be your only failure.
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode('partial output...'))
await new Promise((resolve) => setTimeout(resolve, 800))
controller.error(new Error('simulated mid-run failure'))
}
})The question to answer before the demo, not during it: what does the half-written message do now? Does it stay on screen, greyed and annotated? Vanish? Sit there looking finished and correct while being neither? There is no default answer, and whichever one your prototype has today, it has by accident until you have seen it happen.
What Playwright does and does not give you
If your kit already runs Playwright, the temptation is to fake latency there instead. Be precise about what its documentation actually offers. Playwright’s network and mocking pages document request interception through page.route(), with the route then fulfilled by route.fulfill(), passed through with route.continue(), or killed with route.abort(). Microsoft publishes Playwright and these pages, which is the usual caveat.
Neither of those two pages documents a delay or latency-injection primitive. Every delay recipe found while researching this lesson came from third-party blog posts wrapping route.continue() in a setTimeout promise, not from Playwright’s own documentation. State the absence at the strength it was actually established: those two pages were read and contain no such API. That is not a proof that nothing anywhere in Playwright’s documentation tree does this.
The practical reading is that Playwright’s interception is aimed at the requests a page makes during a test, and hand-rolled timing on top of it is a workaround rather than a supported feature. For a mock you want running in the dev server, in front of a person, MSW or your own route handler is the better-supported path.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your mock reproduces token cadence, a tool-call pause and a mid-run failure. Name one thing you now know about your prototype that you did not know before, and one thing you still do not.
Check your answer
You now know what every waiting state actually looks like for long enough to judge it, and what the surface does with output that was interrupted. Those are design answers you could not have reached by reasoning, because a state you never saw is a state you never decided.
What you still do not know is whether your durations resemble the real system’s. You picked them. They are a hypothesis about latency wearing the costume of a measurement, and the first time a real backend runs behind this surface is the first time that hypothesis gets tested. Write the numbers into the manifest so the next person — probably you — can see they were chosen rather than observed.
Hands on
Make the fake behave badly on purpose
Done when: The kit’s mock endpoint has three switchable scripts — a normal run with paced tokens and a tool-call pause, a mid-run error, and an aborted transport — each reachable without editing code, and the chosen durations are recorded in the manifest as chosen rather than measured.
- Take the scripted route from the previous lesson and space its
text-deltaparts. Watch the surface at your chosen rate, then at half and double it. Pick a rate and write down why. - Insert a wait between
tool-input-availableandtool-output-available. Set it long enough that you have to look at the screen and decide whether what is there is acceptable. Whatever you change as a result is the return on this step. - Add a second script that emits two or three text deltas and then the protocol’s
errorpart. Do not fix what you see immediately. Write down what the surface did first. - Add a third script that errors the
ReadableStreamrather than emitting an error part, and compare. If your surface treats them identically, that is a decision — make it deliberately. - Make the three scripts selectable at runtime, through a query parameter or a header. A failure mode you have to edit code to reach is a failure mode you will not demo.
- Record the durations in the kit manifest under the mock’s entry, labelled as chosen values, and bring them into the chat. I will push on any number that has no reasoning next to it, and hardest on the tool pause, since it silently decides how much ceremony the running state needs.
What this does not cover
Every number on this page is one you invented, including the ones in the code. That is fine while the mock is obviously a mock. It stops being fine the moment the mock has been around long enough that you trust it, and the drift from the system it stands in for is no longer visible. That is the next lesson, on when the mock starts lying to you.
The approval gate appears here only as a pair of protocol parts with a pause between them. Actually wiring the human review step, and the trace view that reads the same event stream, is the lesson on wiring the trace and the gate once.
Read this next — primary source
Mock Service Worker — delay()mswjs.io — fetched 5 September 2026. MSW documenting its own API; the project is open source with no paid product behind the page, so the bias is weaker than a commercial vendor page but it is still the tool describing itself.
A one-screen API reference that repays reading in full because of its second half. The overloads are obvious; the behaviour worth knowing is that the implicit, argument-free delay is deliberately suppressed inside Node test runs, which means the same handler is slow in your dev server and instant in your tests. That asymmetry is intentional and documented, and it is the sort of detail that turns a mock you trust into a mock that is quietly telling two different stories to two different audiences.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.