Topic 10.1
Perceived Speed
In one line
How fast an AI feature feels depends on what the screen does while it waits: nothing for the first fraction of a second, a quiet sign of life next, real words about real progress after that, and a way to stop when it is slow.
Think of it like this
A waiter who takes your order. If they nod and say "coming right up", ten minutes feels fine. If they walk away without a word, two minutes feels long. The kitchen is equally slow in both cases. The difference is what you were told.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Perceived speed
- How long a wait feels to the user, which can differ a lot from the real time.
- Time to first token
- How long until the first piece of the answer arrives. The longest empty stare of the whole request.
- Optimistic UI
- Showing the effect of an action immediately and fixing it only if the server later says no.
- Typing indicator
- A small animated sign (usually three dots) that says the assistant is working on a reply.
- Skeleton
- A grey placeholder in the shape of the content that is about to load.
- Progress words
- A short sentence about what is happening now, taken from real events such as a tool starting.
- submitted
- The
useChatstatus after sending and before the first part of the reply arrives. - Layout shift
- Content that jumps because something above it changed size after it was drawn.
Step by step
01Name the stages of waiting
A request has these stages as seen by useChat: ready (idle), submitted (sent, nothing back), streaming (parts arriving), ready again (done) or error. The screen decides what to show at each stage and, inside submitted, how long the user has already waited.
The first live example is a pure function from "how long has the user waited" and "has text arrived" to "what to show". Pure functions are easy to test: you can see every threshold in the console without waiting for real seconds.
02Show the user's message first
In the second example, read the log. submitted already lists user in messages: the SDK put the user's message in the list before any reply. The list changes again at streaming when an empty assistant message appears, and a third time when the text is done.
On a failing request (the example's second question gets a 503), the user message is still there and status is error. That is the state to design for: the question stays visible, an error line appears, and the user can try again without retyping.
03Typing dots only while nothing has arrived
The typing bubble is shown only when status === "submitted". Once streaming starts, the growing text is the sign of life. Showing dots and text at once is a common mistake: it looks like two assistants.
04Take progress words from the stream
The third example sends a question that makes the model call a tool. Look at how progressWords works: it takes the assistant message's parts, drops bookkeeping parts (step-start) and empty text, and looks at the last part. A tool-getOrder part that is not finished means "Looking up your order…". A finished one means "Found it. Writing the answer…". Real text means no words are needed.
The words follow the events. If the tool took ten seconds, the words would stay for ten seconds, and if it failed they would not claim progress.
05Keep the layout still
Reserve room for the things that appear and disappear. A typing bubble that is removed when text arrives should be replaced by the message in the same place, not push everything down and then up. If you animate, respect the reduced-motion setting (Topic 10.6).
06Make a slow request stoppable
After about 10 seconds the user needs a way out. In Mitra the Stop button (Phase 3) is available from the first moment; this topic only makes it more visible and adds a sentence. The point is respect: do not hold someone in a wait they cannot leave.
Try it yourself
- 1
Move the thresholds
In the first example change the
300to100and the2000to1000. Run and read which lines changed.There is no single right set of numbers. Faster thresholds suit a product where answers are usually quick; slower ones suit one with tool calls. Measure your real time to first token and choose from that.
- 2
Lengthen the tool
In the third example change
80to400and run again.The log is identical. The words follow the events, so a slower tool only means the same words stay longer. That is the advantage of driving progress from the stream.
- 3
Show the dots during streaming too
In the second example change
status === "submitted"tostatus !== "ready"and look at the page after the first answer starts to appear.The typing bubble stays under the growing text. It reads as a second, silent assistant. One sign of life at a time.
Code & diagrams
This example does not wait for real seconds: it asks the function what it would show after each waiting time. The last line shows that once text has arrived, the waiting signs go away. The thresholds are a starting point; test them with your own users.
Preview
Console
Nothing logged yet.Expected console output
0 ms: nothing | words: "" | stop button: false
299 ms: nothing | words: "" | stop button: false
300 ms: typing-dots | words: "" | stop button: true
1999 ms: typing-dots | words: "" | stop button: true
2000 ms: typing-dots | words: "Looking that up…" | stop button: true
9999 ms: typing-dots | words: "Looking that up…" | stop button: true
10000 ms: typing-dots | words: "This is taking longer than usual. You can stop and try again." | stop button: true
12000 ms: answer | words: "" | stop button: trueRead the list in the log: the user message is there at submitted, before any reply. The second question fails with a 503, the SDK prints the failure on its own line, and the question is still in the list. The effect also presses the second button for you once the first answer is finished.
Preview
Console
Nothing logged yet.Expected console output
status: ready | messages: none
status: submitted | messages: user
status: streaming | messages: user, assistant(empty)
status: ready | messages: user, assistant
AI_APICallError: overloaded
status: submitted | messages: user, assistant, user
status: error | messages: user, assistant, user
error shown, question still in the list: trueThe words change when the stream's parts change, not on a clock. The tool takes 80 ms here, so "Looking up" is visible; with a slow tool it would stay as long as the tool runs. When text arrives the words clear themselves.
Preview
Console
Nothing logged yet.Expected console output
status: submitted | words: "Sending your question…"
status: streaming | words: "Looking up your order…"
status: streaming | words: "Found it. Writing the answer…"
status: streaming | words: ""
status: ready | words: ""Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Show the spinner immediately
In the first example delete the line that begins if (waitedMs < 300) return, then run.
Break #2
Do not skip bookkeeping parts
In the third example delete p.type !== "step-start" && !(p.type === "text" && !p.text) from the filter, so every part is considered.
Myth vs fact
Myth
A spinner is always better than nothing.
Fact
For waits under about a third of a second it is worse: it appears and vanishes as a flicker. Show a sign only when the wait is real.
Myth
Rotating friendly messages ("Brewing ideas…") make waiting feel shorter.
Fact
They can, but they are not true. If they keep rotating over a request that has failed, they mislead. Tie words to real events.
Myth
Streaming makes the answer arrive sooner.
Fact
The total time is the same or slightly longer. Streaming makes the wait feel shorter because the user starts reading at the first word.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Measure time to first token in production and chart it as a percentile (p50 and p95), not only an average. The slowest 5% of requests are where the 10-second sentence earns its place. Phase 13 covers metrics.
- ▸
useChataccepts athrottleoption (milliseconds) that limits how oftenmessagesupdates during a stream. If a long answer makes the page feel heavy because every token re-renders a large list, throttling to around 30 to 50 ms keeps the text smooth and the page responsive. It does not change when the data arrives, only how often React draws it. - ▸
Waiting also happens in the middle of an answer. If the connection stalls after the first words, the screen looks finished but is not. Keep a timestamp of the last chunk and, after a few seconds without one, add a quiet "Still working…" next to the partial text with Stop within reach (Topic 3.3 covers a stream that breaks).
- ▸
Do not animate layout properties (height, top) on every token. Text growing in an element is already a layout change; adding an animated height on top of it is the usual source of jank. Keep animation to opacity or transform and keep it brief.
Remember this
- 1
The problem this solves: A model often needs a second or more before the first word, and sometimes much longer when it calls tools. A frozen screen makes people click again, reload, or leave. A screen that fakes progress with a spinner that never changes teaches them not to trust it. This topic shows what to show at each stage of waiting, using signals that are true.
- 2
Two kinds of speed. *Real* speed is the time the system takes. *Perceived* speed is how long it feels. You can improve the second without touching the first. Streaming (Phase 2) is the biggest tool: the user reads while the rest is written. The other tools in this topic are the ones around it.
- 3
Rules of thumb for waiting. A well-known article on response times (Nielsen Norman Group) names three limits. About 0.1 s: the system feels instant and needs no extra feedback. About 1 s: people notice the delay and need a sign that work is happening. About 10 s: attention starts to drift, so show progress and give a clear way to interrupt. These are guides, not laws. This topic uses them as: under 300 ms show nothing extra, from 300 ms show a quiet typing sign, from 2 s add words, from 10 s say it is slow and make Stop obvious.
- 4
Why "nothing" for the first 300 ms. A spinner that appears and vanishes inside a quarter of a second is a flicker, and it looks like a glitch. If the answer is fast, the less you show, the calmer it looks. Show a loading sign only once it is clear that the wait is real.
- 5
Optimistic UI means showing the result of an action before the server confirms it. For chat this is easy and always right: the user's own message appears in the list at once, because we know what they typed. The AI SDK does this for you:
useChatadds the user message tomessagesbefore the request even reaches your server. What you must add is the failure side: if the request fails, the message stays, an error is shown next to it, and a retry is offered. - 6
Progress words must be true. "Looking up your order…" is helpful only if the model really is calling the order tool. Drive the words from what the stream says (a tool part is running, the first text has arrived), never from a timer that rotates through friendly sentences. A timer writes "Analysing your request…" over a request that has already failed. Phase 7 and 8 explain tool parts; here you only read their
state. - 7
A skeleton is a grey placeholder shaped like the content that will come (a few lines for a message). It is good for content whose shape you know, like a card. For a streaming chat answer the shape is unknown and the text itself is the progress, so a typing indicator before the first token is usually enough. Whatever you choose, keep the layout steady: do not let the list jump when the first word arrives.
Explain it without notes
Why does Mitra show nothing extra for the first 300 ms of a request?
What is optimistic UI in a chat, and what must you build to go with it?
Why should progress words come from stream events instead of a timer?
A chat answer takes 15 seconds when the model calls a slow tool. What does the screen show over that time?
Practice
In the third example, add a second tool part named tool-getPolicy and make progressWords return "Checking the return policy…" while it is running. Keep the other branches working.
Write a function waitingTone(waitedMs) that returns "calm" under 2 s, "informative" from 2 s to 10 s and "apologetic" after that, and log it for 100, 2000 and 10000.
Trade-offs
- ↔
Quiet screens look calm but can leave a user unsure that anything happened; busy screens reassure but can flicker or lie. The grace period plus true words is a middle path, at the cost of a little more code.
- ↔
Throttling updates saves rendering work on long answers but makes text arrive in slightly bigger steps. Use it when profiling shows a problem, not by default.
Done when you can
I can say what Mitra shows at 0 ms, 300 ms, 2 s and 10 s of waiting, and why.
I can explain what optimistic UI means for a chat and what the error side needs.
I can drive progress words from a tool part's state.
I can name the signs to avoid: instant flickering spinners, fake rotating text, dots plus text together.
I can make a slow request easy to stop.