Sep 21, 2026

Designing the wait

Every interface I build with a model in it has the same problem. Something takes between four hundred milliseconds and thirty seconds, and I don’t know which until it’s over.

Loading used to be an edge case. You designed the screen, then you designed the spinner. With a model in the loop the wait isn’t an edge case anymore, it’s most of the interaction. It’s where people decide whether the thing works.

So I stopped treating latency as something to hide and started treating it as a material. Here’s what I settled on, and the demos I used to decide.

Revenue is up 12% this month. Almost all of the lift came from returning customers, not from new acquisition or price changes.

Three thresholds

There are only three numbers that matter, and they haven’t moved since 1968.1

Under 100 ms, a response feels instantaneous. Nothing needs designing. A loading state here makes the interface slower, not more polished.

Under 1 second, the train of thought survives. People notice the delay and stay. You need to show that something happened, not what.

Past 10 seconds, attention is gone. They switch tabs. Everything after this point has to survive being abandoned and come back to.

Almost every mistake I see is effort spent in the wrong band. A skeleton screen on a 120 ms filter. A bare spinner on an eleven-second agent run.

100 ms2481 results
1 s2481 results
10 s2481 results

Spend the first 200 ms on the user, not the model

The clock people feel doesn’t start when your request resolves. It starts at the click.

So the cheapest win has nothing to do with the model. Render the user’s own input immediately. Their message lands in the thread, the composer clears, send becomes stop. None of that waits on a network response, because none of it depends on one.

That buys you the whole first second. The same four-second answer gets described as fast or slow depending on what happened in the first hundred and fifty milliseconds.

The rule: nothing that can be rendered from local state is allowed to wait for the server.

RecommendedEchoes instantly

Not recommendedWaits for the server

Send a message in both. The answer takes exactly as long on each side.

Pick the loader by duration, not by taste

A spinner makes a promise it can’t keep. It spins the same way for eight hundred milliseconds and for forty seconds, so it says nothing about the wait, only that nobody measured it.

I pick by band. Under 1 s, nothing, or the button’s own pressed state. One to four seconds, a skeleton of the shape that’s coming, but only if you actually know the shape. Four to fifteen, elapsed time and a label naming the current step. Past fifteen, the wait needs its own surface and it needs to survive a reload.

A skeleton is only honest when the layout is known. Three grey lines that resolve into a table is worse than no skeleton at all. You promised a shape and delivered a different one.

When you don’t know the shape, you almost always know something else. If part of the result is already in hand, render that part and leave the rest pending: content beats a promise of content. If all you know is what the system is doing, name the step. And if there is already a result on screen from last time, the cheapest correct answer is to leave it there. Drawing a shape you can’t keep is the only one of these that costs you trust.

1.2 s11 s
SpinnerRevenue is up 12% this month. Revenue is up 12% this month.
SkeletonRevenue is up 12% this month. Revenue is up 12% this month.
ElapsedRevenue is up 12% this month. Revenue is up 12% this month.

RecommendedSkeleton

RecommendedElapsed

Past fifteen seconds there is a fourth option: entertainment. Something to watch while you wait.4 It genuinely works. Filled time is remembered as shorter than empty time. I still don’t ship it. It competes for the attention you need in order to notice the answer landed, and a delight that stops delighting on the fiftieth run is a daily cost for a one-time laugh.

Compare against which period?

1/2

RecommendedSomething to answer

Not recommendedSomething to watch

Not recommendedGamification

Show elapsed, not progress

You cannot know how far along a model is. A progress bar that stalls at 90 % is a lie people catch you in.

Elapsed time is honest and costs nothing. It says: I’m still here, this is what you’ve spent, decide what you want to do with that.

Two details make it feel right. Set it in tabular figures, or the digits change width on every tick and the row twitches. And measure wall time with performance.now() instead of counting setInterval ticks. A counter that adds a fixed 0.1 s per tick isn’t reading a clock, it’s reading a tick count. I measured 1 ms of drift over 5 s in a foreground tab, so it looks correct. Then the reader switches tab, the browser clamps the interval to a second, and the number reports a tenth of the real wait. The interval decides how often you look at the clock. It is never allowed to be the clock.

One more, and it’s the one that gets reversed most often. Under prefers-reduced-motion, freeze the loader and let the timer keep counting. Reduced motion exists to remove movement, not information. The spinning was decoration; the number is the only part of that component doing any work.

RecommendedElapsed

0%

Not recommendedProgress bar

One of them stops telling the truth. The other cannot.

Streaming has a cadence

Tokens don’t arrive evenly. They arrive in bursts with stalls between them. Paint each chunk the moment it lands and the text stutters. And stutter reads as broken far more than slow ever does.

So buffer, then re-emit on your own clock. Evenness matters more than speed.

The speed comes out of the reader, not the model. Adults read non-fiction English at about 238 words a minute2, one word every 252 ms. Emit at that rate and the reader rides the edge of the stream: the first stall and they’re waiting on you. Emit at roughly four times it and every second of streaming builds about a line of runway ahead of the eye, which is what makes text feel like it was already there. That puts the cadence at 60 ms a word, and it comes from the reading rate, not from a feel.

One constant isn’t enough, though. Emitting faster than chunks arrive drains the buffer and puts the stutter straight back. So the cadence tracks the rolling mean arrival rate of the last chunks, clamped at both ends: never slower than 120 ms a word, where the reader catches up, and never faster than 40 ms, where evening it out stops buying anything.

Words beat characters. Character-by-character is a typewriter effect, and a typewriter is a costume, not a signal.

40 ms

Recommended60 ms

120 ms

Not recommended250 ms

Raw bursts take exactly as long. Evenness is doing all the work.

A response doesn’t have one arrival, it has several. First token, then prose at its cadence, then a citation that resolves when its source does rather than when the sentence reaches it, then the last token. Then, later again, the things you can do with the answer.

Readable, complete, usable, in that order. And it is the interface's job to keep it. A copy button that appears with the first token copies half a sentence. Retry offered mid-stream retries something that had not finished arriving. Both come from binding the actions to the request resolving instead of to the stream ending: one word of difference in the code, and the whole difference on screen.

Returning customers are your strongest segment: revenue from them is up 23% this month, and their margin beats new acquisition by 8 points. warehouse.sql Referral signups are trending in the same range.
Warehouse querywarehouse.sqlRevenue modelmetrics.internalCohort exportcohorts.csv

Follow-ups

Which regions drove the lift?Compare margin by acquisition channel

Recommended

Don’t reflow what you already showed

Streaming breaks a rule the rest of the web takes for granted. Text above the cursor is supposed to stay where it is.

Three things move it. text-wrap: balance re-breaks the entire paragraph on every append. Markdown parsed mid-stream turns a line into a heading, then a list, then back. And a code fence that opens before it closes resizes as it fills.

In order of how much they buy you: leave the wrapping alone while text is arriving. I measured one paragraph streaming word by word into the column below. balance re-broke the first line nine times, pretty four, plain wrapping not once. pretty is the one everyone reaches for, and it is half as bad rather than harmless: apply it once the text has finished, not while it arrives. Parse markdown on completed blocks only and render the incomplete tail as plain text. Reserve a code block’s height from its first line rather than its last.

None of this is visible when it works. All of it is visible when it doesn’t.

Revenue is up 12% this month. Almost all of the lift came from returning customers, not from new acquisition, and the pattern holds in every region except the south.

Not recommendedtext-wrap: balance·0re-breaks

Revenue is up 12% this month. Almost all of the lift came from returning customers, not from new acquisition, and the pattern holds in every region except the south.

Not recommendedtext-wrap: pretty·0re-breaks

Revenue is up 12% this month. Almost all of the lift came from returning customers, not from new acquisition, and the pattern holds in every region except the south.

Recommendedtext-wrap: wrap → pretty·0re-breaks

Same text, same cadence. Watch the counters, not the words.

Make the wait interruptible

An animation you can’t interrupt feels broken.3 A wait you can’t interrupt feels like a hostage situation.

Stop has to be a real control, which means three things.

It sits exactly where send was. The cursor is already there. A stop button placed anywhere else is a stop button nobody presses.

It stops on press, not on the server’s acknowledgement. Abort locally, then tell the server.

It keeps what already arrived. A cancelled answer that erases itself punishes people for stopping, so next time they stop the other way: they close the tab.

How did revenue do this month?

RecommendedKeeps the partial answer

How did revenue do this month?

Not recommendedDiscards the partial answer

Stop it halfway: letting it finish shows nothing. Only one of them respects what you already read.

Failure is a latency state

An error after eleven seconds isn’t an error screen. It’s the worst available ending to a wait, and it belongs to the wait.

Keep the partial output. Keep the prompt exactly as it was typed. Put retry where stop was. And name what failed: “something went wrong” after eleven seconds is an insult with a delay attached.

How did revenue do this month?

RecommendedNames what failed

How did revenue do this month?

Not recommendedSays something went wrong

Let it fail. The part you already read should still be there.

The numbers I use

ConstantValueWhy this value
INSTANT_ECHO0 mslocal state never waits on a network
LOADER_AFTER400 msbelow it the loader flashes, and the flash costs more than the wait
ELAPSED_AFTER4 swhere “still working” stops being reassuring on its own
STREAM_UNITwordcharacters are a costume
STREAM_CADENCE60 ms, adaptive≈ 4× the 238 wpm reading rate; tracks arrival, clamped 40–120 ms
ABANDON_AFTER10 sattention is gone; the wait has to survive a tab switch
STOP_LATENCY0 msabort locally, tell the server after

Notes

  1. 1Robert B. Miller, Response time in man-computer conversational transactions, 1968: the 0.1 s / 1 s / 10 s thresholds. Popularised by Jakob Nielsen, Response Times: The 3 Important Limits, 1993.
  2. 2Marc Brysbaert, How many words do we read per minute? A review and meta-analysis of reading rate, Journal of Memory and Language 109, 2019 (190 studies, 18 573 participants): 238 wpm for non-fiction, 260 for fiction.
  3. 3Jakub Krehel, Details that make interfaces feel better, on interruptibility.
  4. 4The mocks in this article owe Beautiful UI three things: the composer every chat panel is built on, a port of its ChatComposer; the shape a streamed answer resolves in, after its StreamingText; and the Surfer variant of its Loading State, which is the ambient panel in the comparison above. The clip under that one is gameplay footage of Subway Surfers by SYBO Games, self-hosted rather than pulled from theirs.