completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Perceived performance

Make waiting feel shorter: what to show during a wait, skeletons, spinners and progress bars, optimistic updates, showing content first, and what the metrics still see.

By the end, you can
  • 01
    Pick the right feedbackChoose nothing, a spinner, a skeleton, a percent-done bar or an optimistic update, from how long a wait lasts and what it's for.
  • 02
    Tell the feel from the numbersSay what a spinner or a skeleton changes in a recorded load: how the wait looks and the layout shifts, but not the LCP.
  • 03
    Show useful content soonerShow each part of a page as it arrives, and avoid the tricks that backfire: flashing spinners, stalling bars and quiet failures.

Making a wait feel shorter

Asha is on the bus again, with Leaf and Kettle open on her phone. She taps Tea of the week. The shop's name and the heading appear. Then nothing happens. The space under the heading stays empty. Is the page broken? Should she tap again?

Ravi looks after the shop's website, and he knows why. The page asks the shop's server for the tea and for its reviews. The database behind the server is slow. It takes 1 second to send the tea and 3 seconds to send the reviews. Ravi can't make it faster yet.

The course recorded this page loading the way Asha's phone loads it. Here are two times from one load:

How long the space stayed emptyPop out in the code editor (opens in a new tab)JavaScript
// One recorded load of the blank version, in milliseconds.const headingShown = 1216;const teaShown = 4240;const emptyWait = teaShown - headingShown;console.log(`For ${emptyWait} ms, the space under the heading stayed empty.`);
  • Line 1 is a comment. The times are in milliseconds (ms), from the start of the load. A millisecond is a thousandth of a second.
  • Line 2 is when the shop's name and the heading appeared. Line 3 is when the tea and its reviews appeared.
  • Line 4 subtracts one from the other. Line 5 prints For 3024 ms, the space under the heading stayed empty.

Here is Asha's screen at three moments of that load:

Asha's phone screen at three moments of one recorded load. At 1,216 ms, the heading appears: Leaf and Kettle and Tea of the week, with no tea and no reviews under the heading. At 2,219 ms, the tea arrives, but isn't shown: Leaf and Kettle and Tea of the week, with no tea and no reviews under the heading. At 4,240 ms, the tea and the reviews appear: Mint tea, its description and price, and three reviews under Tea of the week. The space under the heading stayed empty for 3,024 ms.1,216 msLeaf and Kettle and Tea of the week, with no tea and no reviews under the headingthe headingappears2,219 msLeaf and Kettle and Tea of the week, with no tea and no reviews under the headingthe tea arrives,but isn't shown4,240 msMint tea, its description and price, and three reviews under Tea of the weekthe tea and thereviews appearnothing under the heading: 3,024 ms
Asha's screen at three moments of the recorded load: real frames Chrome captured while it loaded. The tea's answer was in at 2,219 ms, but the screen didn't change until 4,240 ms. The screens are evenly spaced, not to scale.

That's about three seconds of empty space. Look at the middle screen: the tea has already arrived, but the screen hasn't changed. Why? The page's script waits for both answers before it shows either. The end of this lesson fixes that. But first, Ravi wonders: can he make those three seconds feel shorter? And would that change the numbers Google's tools use to judge the page?

Lesson 1.1, What is web performance?, gave the first question a name. How fast a page feels to the person using it is its perceived performance. Remember the two lifts that took just as long: the one with a floor display was easier to wait for.

Real-life analogyBread and water while the kitchen cooks

You sit down in a busy restaurant. The kitchen needs a while to cook your meal. In one restaurant, nobody comes, and you stare at an empty table. In another, a waiter brings bread and water as soon as you sit down. The main course arrives at the same time in both. But the second wait feels shorter, because you had something to start on.

In real life: The time the kitchen needs to cook
In web performance: The time the server needs to answer
In real life: Sitting at an empty table
In web performance: An empty space on the screen
In real life: Bread and water, brought as soon as you sit down
In web performance: Something useful on the screen early, like the tea before its reviews
In real life: The main course, at the same time as before
In web performance: The rest of the page, when the server sends it
In real life: A wait that feels shorter
In web performance: Better perceived performance

Ravi tries four versions of the page. Each one shows something different while it waits:

  • Blank: nothing where the tea and the reviews go, until both answers are in.
  • Spinner: a small turning circle, with the words Loading the tea of the week.
  • Skeleton: grey bars in the shape of the tea and the reviews.
  • Content first: the same grey bars, but the tea replaces its bars as soon as its answer arrives. It doesn't wait for the reviews.

The course loaded each version in real Chrome 156. All four show the shop's name at about the same moment. The first three then look different while they wait, but the tea appears at the same moment in all three. Only content first shows the tea sooner, about two seconds sooner. The rest of this lesson compares the four recordings.

The main idea

Perceived performance is how fast a page feels. You can't always make a wait shorter, but you can choose what fills it. Show something useful early. Keep people busy and informed. Keep the space for what's coming.

It doesn't replace real speed. The numbers that judge loading measure when the content shows up, not how the wait felt.

Active vs passive waiting

Back to Asha's three empty seconds. A stopwatch would show the same time whatever the page looks like. So why can one wait feel longer than another?

In 1985, David Maister wrote an essay called The Psychology of Waiting Lines, about queues in restaurants, airports, supermarkets and doctors' waiting rooms. From everyday examples, he wrote down rules for how waits feel. He called them propositions: ideas for later research to test. The first one is "Occupied time feels shorter than unoccupied time." That gives two kinds of wait:

  • A passive wait has nothing in it but the wait: an empty space, a frozen button.
  • An active wait gives people something to look at, read or do.
Real-life analogyThe long walk to the baggage belt

Alex Stone told this story in The New York Times in 2012. Passengers at a Houston airport complained about the wait for their bags. They walked for a minute to the baggage belt, then stood there for seven more. This time the airport didn't try to make the bags faster. It moved the arrival gates further away, and sent the bags to the furthest belt. Passengers now walked six times as long. Complaints dropped to near zero.

In real life: Standing at the belt, watching it go round
In web performance: A passive wait: an empty space on the screen
In real life: Walking to the belt
In web performance: An active wait: something to look at, read or do
In real life: The airport didn't make the bags any faster
In web performance: The server answers no sooner
In real life: Complaints that almost stopped
In web performance: A wait that feels shorter

The walk didn't bring the bags sooner. It filled the wait. It's a newspaper story, not an experiment, but it matches Maister's first proposition. Each row below is one of his propositions, and what a page can do about it:

What makes a wait feel longer
What Maister wroteWhat a page can do
Occupied time feels shorter than unoccupied time.Give people something to read or do: content that's already here, a form to fill in, a choice to make.
Waits before the service starts feel longer than waits during it.Show at once that the tap was received: a pressed button, the page's frame, a first step.
Uncertain waits are longer than known, finite waits.For a wait over about 10 seconds, say how long it will take, or how much is done.
Unexplained waits are longer than explained waits.Say what's happening: Loading your orders, or Uploading 3 of 12 photos.
Anxiety makes waits seem longer.Never leave people wondering whether it worked, or whether to tap again.

Does this hold for web pages? Nielsen Norman Group (NN/g), a company that researches how people use websites, reports a study from the University of Nebraska-Lincoln. While a page loaded, one group saw a looping progress bar: a bar that kept moving, without showing how much was done. The other group saw nothing. The group with the moving bar was willing to wait about three times longer, on average.

Why it matters: an empty wait feels longer, and people are less willing to sit through it. Try it on yourself. The panel makes you wait twice, for lengths you can't see: once with nothing to do, once with steps to read and teas to pick. After each wait, guess how long it was:

Playground: how long did that wait feel?
What Start runs, shortenedJavaScript
function startWait(kind, seconds) {  const started = performance.now();  if (kind === "active") showSteps(["Finding your order", "Checking stock", "Packing your tea"]);  else showText("Please wait.");   setTimeout(() => {    const real = (performance.now() - started) / 1000;    askForGuess(real);  }, seconds * 1000);}
The waitwait 1 of 2 next

No waits yet this round.

Try it yourself
Your guesses

Press Start. You'll wait twice, for lengths you can't see: once with nothing to do, once with something to read and do. After each wait, guess how long it was.

A demo with timers: each wait is a real pause of 3 to 7 seconds, measured with performance.now(). Nothing is loading. Only what you see during the wait changes.

In the panel's code:

  • Line 3 starts the active wait. It shows steps one after another, and lets you pick teas.
  • Line 4 starts the passive wait: the words Please wait, the whole time.
  • Line 7 measures how long the wait really took. Your guess is compared with that.

One round says little. Play a few, and compare your guesses for the two kinds.

Skeletons and spinners

What to show depends on how long the wait is

Ravi's first idea is to put something in the empty space. But what? Lesson 2.1, 0.1 seconds, 1 second, 10 seconds, ended with a rough rule. NN/g's articles name the kind of feedback for each length of wait:

What to show during a waitPop out in the code editor (opens in a new tab)JavaScript
function feedbackFor(ms) {  if (ms < 1000) return "nothing extra: just show the result";  if (ms < 10000) return "a spinner, or a skeleton for a whole page";  return "a percent-done bar";} for (const ms of [400, 3000, 15000]) {  console.log(`${ms} ms: ${feedbackFor(ms)}`);}
  • Line 1 starts a function. It takes a wait, in milliseconds, and says what to show.
  • Line 2: under 1,000 ms, show nothing extra. The result comes so soon that a loading sign would only flash.
  • Line 3: under 10,000 ms, show a spinner or a skeleton. A spinner is a small animation that turns round and round. A skeleton is a set of grey shapes where the content will go.
  • Line 4: from 10,000 ms on, show a percent-done bar. It fills up as the work gets done.
  • Lines 7 to 9 try three waits. The first prints 400 ms: nothing extra: just show the result.
A time line where each mark is ten times the one before: 1 second and 10 seconds. Under 1 second, just show the result. From about 1 to 10 seconds, show a spinner or a skeleton. From 10 seconds on, show a percent-done bar. The waits 400, 3,000 and 15,000 milliseconds sit in the three stretches, one in each.1 s10 sjust show the result400spinner or skeleton3,000percent-done bar15,000
What to show during a wait, from NN/g's advice. The example's three waits, 400, 3,000 and 15,000 ms, fall one in each stretch.

Each row of this table is one kind of feedback, when NN/g advises it, and what to watch for:

What to show during a wait
What to showWhen it fitsWhat to watch for
Nothing extraUnder about 1 second.A loading sign would only flash, and a flash distracts.
A spinner: a small animation that turnsAbout 2 to 10 seconds, for one part of the page.Say what's loading. Past 10 seconds, people can't tell whether it's stuck.
A skeleton: grey shapes where the content will goUnder 10 seconds, for a whole page that's loading.Copy the shapes of the real page, so people can see what's coming.
A percent-done bar10 seconds or more, or a known number of steps.Don't race ahead and then stall near the end.
Words that never change: Loading…Avoid them.Nothing moves, so people can't tell whether anything is happening.

Asha's wait is about three seconds, and most of the page is waiting. So a spinner or a skeleton fits. The course measured both.

The skeleton, in code

Here is the skeleton version's HTML. The four versions differ only inside <main>:

index.html: the skeleton versionHTML
<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>Leaf and Kettle</title>  <link rel="stylesheet" href="styles.css">  <script src="app.js" defer></script></head><body>  <header>Leaf and Kettle</header>  <main class="reserved">    <h1>Tea of the week</h1>    <section id="tea" aria-busy="true">      <div class="bar title"></div>      <div class="bar"></div>      <div class="bar"></div>      <div class="bar"></div>      <div class="bar short"></div>    </section>    <h2>Reviews</h2>    <section id="reviews" aria-busy="true">      <div class="bar"></div>      <div class="bar"></div>      <div class="bar short"></div>    </section>  </main>  <footer>Free delivery on every order over 2,000 cents.</footer></body></html>
  • Line 12 gives the main part the class reserved, which keeps space for the tea and the reviews.
  • Lines 14 to 20 are the tea's grey bars. aria-busy="true" tells screen readers that this part is still loading.
  • Lines 22 to 26 are the reviews' grey bars.

And here are the styles all four versions share:

styles.cssCSS
body { margin: 0; font-family: system-ui, sans-serif; color: #1d1830; }header { padding: 16px; font-weight: 700; background: #f4efe4; }main { padding: 16px; }#hero { display: block; max-width: 100%; height: auto; }button { padding: 12px 20px; font-size: 16px; }h1 { margin: 0 0 8px; }#tea, #reviews { display: flow-root; }#about { font-size: 18px; line-height: 1.5; }.price { font-weight: 700; }#reviews ul { margin: 0; padding-left: 20px; line-height: 1.6; }footer { padding: 24px 16px; background: #f4efe4; }.reserved #tea { min-height: 260px; }.reserved #reviews { min-height: 96px; }.bar { height: 18px; margin: 0 0 14px; border-radius: 4px; background: #e8e2d6; }.bar.title { width: 50%; height: 28px; margin: 20px 0 22px; }.bar.short { width: 60%; }.spinner { display: flex; align-items: center; gap: 12px; color: #5c5670; }.spinner::before { content: ""; width: 24px; height: 24px; border: 3px solid #e8e2d6; border-top-color: #1d1830; border-radius: 50%; }@keyframes spin { to { transform: rotate(1turn); } }@keyframes pulse { to { opacity: 0.5; } }@media (prefers-reduced-motion: no-preference) {  .spinner::before { animation: spin 1s linear infinite; }  .bar { animation: pulse 0.9s ease-in-out infinite alternate; }}
  • Lines 1 to 5 are the tea shop's styles from lesson 1.1.
  • Lines 12 and 13 keep the space. min-height makes a box at least that tall, even while it's empty.
  • Lines 14 to 18 draw the grey bars and the spinner's circle.
  • Lines 21 to 24 make them move, but only for people who haven't asked their system for less motion with prefers-reduced-motion.

What the recordings show

The course loaded each version in real Chrome 156, set up like Asha's phone: a phone-sized screen, DevTools' Slow 4G network, and the processor slowed down four times. It kept the middle one of five loads. Reduced motion was on, so the spinner doesn't turn in the frames below.

Three numbers from each load answer Ravi's second question. The last two are Core Web Vitals: numbers Google's tools use to judge how a page feels to use.

  • First contentful paint (FCP): when the browser first drew any text or image of the page. Here, that's the shop's name and the heading.
  • Largest contentful paint (LCP): when the largest text or image on the screen was drawn. It answers "when did the main content show up?"
  • Cumulative layout shift (CLS): a score for how much things on the screen jumped to a new place. 0 means nothing moved.

Here is the spinner version, frame by frame:

  1. Nothing drawn yet

    0 ms to 1,219 msA blank screen

  2. The shop's name, Tea of the week, a spinner with the words Loading the tea of the week, and the rest of the page close under it.

    At 1,219 msA spinner

  3. Mint tea, its description and price, and three reviews. Everything below the tea has moved down.

    At 4,241 msTea and reviews: the LCP

The spinner version, recorded in Chrome 156.

And the skeleton version:

  1. Nothing drawn yet

    0 ms to 1,229 msA blank screen

  2. The shop's name, Tea of the week, and grey bars in the shape of the tea and the reviews.

    At 1,229 msA skeleton

  3. Mint tea, its description and price, and three reviews, where the grey bars were. Nothing else has moved.

    At 4,238 msTea and reviews: the LCP

The skeleton version, recorded in Chrome 156.

Each row of this table is one version, what it showed while it waited, and its three numbers:

The four recorded loads
VersionWhile it waitsFirst contentful paintLCPCLS
BlankNothing where the tea and reviews go, until both answers are in.1,216 ms4,240 ms0.09
SpinnerA spinner and Loading the tea of the week, until both answers are in.1,220 ms4,240 ms0.07
SkeletonGrey bars the size of the tea and the reviews, until both answers are in.1,228 ms4,236 ms0
Content firstThe same skeleton, but the tea shows as soon as it arrives, and the reviews when they do.1,224 ms2,236 ms0

Now compare the first three rows:

  • The same FCP. All three drew the shop's name around 1,216 ms.
  • The same LCP. They reached it at 4,240 ms, 4,240 ms and 4,236 ms: just after the reviews arrived, at 4,219 ms. All three waited for both answers.
  • A different CLS. The blank and spinner versions scored 0.09 and 0.07: when the content arrived, everything under the tea moved down. The skeleton scored 0.

So the spinner and the skeleton changed how the wait looked, but not when the content arrived. Why didn't LCP count them?

Why the LCP didn't move

LCP looks only at content. web.dev lists what counts: images, videos, and blocks of text. The grey bars are empty boxes filled with a colour, so LCP never picks them. The spinner's words are text, but they're one short line. On this page, the largest block of text is the tea's description, and it can't be drawn before the tea arrives.

Why it matters: a skeleton or a spinner changes how a wait feels. To move the LCP, the content itself has to show up sooner.

Why nothing moved in the skeleton version

A layout shift happens when something already on the screen jumps to a new place. In the blank and spinner versions, the tea needed far more room than its empty space had, so it pushed everything under it down.

The skeleton version had kept the space, with lines 12 and 13 of the styles. The tea dropped into room that was already there, like a group arriving at a reserved table in a café: nobody else has to move their chairs. So the kept space stopped the shift, not the grey bars. web.dev gives the same advice: reserve space for content that loads late, with min-height, a placeholder or a skeleton.

Progress bars that feel faster

Some waits last well over 10 seconds, like building a year of Asha's receipts as one file. For those, NN/g advises a percent-done bar. In a 1985 experiment, Brad Myers found that people prefer to have one.

The shape of the bar matters

Two bars can take the same time and still feel different. Think of a bus ride:

Real-life analogyTwo bus rides of the same length

On Monday, Asha's bus home crawls through town traffic, then speeds along the open road at the end. On Tuesday, it speeds out of town, then sits in a jam two stops from home. Both rides take just as long. Tuesday's feels longer: she was nearly home, and stuck.

In real life: Two rides that take just as long
In web performance: Two progress bars that take just as long
In real life: Crawling through town, then speeding home
In web performance: A bar that starts slowly and speeds up at the end
In real life: Speeding out of town, then a jam near home
In web performance: A bar that races ahead, then nearly stops near the end
In real life: Tuesday's ride feeling longer
In web performance: The bar that stalls near the end feeling slower

In 2007, Chris Harrison and colleagues tested this with progress bars. They showed 22 people pairs of bars. Every bar took 5.5 seconds, but each moved in its own way. After each pair, people said which bar seemed faster. Here are two of their nine bars:

A bar that speeds up at the endPop out in the code editor (opens in a new tab)JavaScript
const steady = (x) => x;const speedsUp = (x) => (x + (1 - x) / 2) ** 8; for (const x of [0.25, 0.5, 0.75, 1]) {  const percent = (bar) => `${Math.round(bar(x) * 100)}%`;  console.log(`After ${x * 100}% of the time: steady ${percent(steady)}, speeds up ${percent(speedsUp)}`);}
  • Line 1 is a steady bar. Each bar is a function. You give it x, the share of the time gone, from 0 at the start to 1 at the end. It gives back the share of the bar to fill.
  • Line 2 is Harrison's "Fast Power" bar. You don't need to follow its formula. It starts slowly and races at the end.
  • Line 4 picks four moments. Line 5 turns a share into a percentage, and line 6 prints both bars.

Half way, it prints After 50% of the time: steady 50%, speeds up 10%. Half the time has gone, and the bar shows a tenth. Now a bar that does the opposite. Harrison called it "Late Pause":

A bar that nearly stops near the endPop out in the code editor (opens in a new tab)JavaScript
const steady = (x) => x;const pausesLate = (x) => x + (1 - Math.sin(x * Math.PI * 2 + Math.PI / 2)) / 8; for (const x of [0.25, 0.5, 0.75, 1]) {  const percent = (bar) => `${Math.round(bar(x) * 100)}%`;  console.log(`After ${x * 100}% of the time: steady ${percent(steady)}, pauses late ${percent(pausesLate)}`);}
  • Line 2 races ahead early, then nearly stops around three quarters of the way.
  • Lines 4 to 7 print the same four moments as before.

Half way, it prints After 50% of the time: steady 50%, pauses late 75%. Then it crawls: from 88% at three quarters of the time to 100% at the end. Here are all three bars at the half-way moment:

Three progress bars, each half way through its 5.5 seconds. Steady shows 50 percent. Speeds up shows 10 percent. Pauses late shows 75 percent.Steady50%Speeds up10%Pauses late75%half of the time gone
Three bars at the same moment: half of their 5.5 seconds gone. All three finish together, but they get there in different ways.

Which felt faster? Only two of the nine bars were judged faster than the steady one: Power and Fast Power. Both made their fastest progress near the end. The three bars that paused near the end, Late Pause among them, were judged slower. That's Tuesday's bus.

Try the bars yourself. Harrison's team noticed that people tended to pick whichever bar they saw first, so the panel mixes up the order of each pair:

Playground: which bar felt faster?
What Run does for each bar, shortenedJavaScript
function runBar(bar, element) {  const started = performance.now();  function onFrame(now) {    const x = Math.min(1, (now - started) / 5500);    element.style.width = `${bar(x) * 100}%`;    if (x < 1) requestAnimationFrame(onFrame);    else finished(now - started);  }  requestAnimationFrame(onFrame);}
Two barsready

Bar 1

Bar 2

Try it yourself
Which felt faster

Press Run. Two bars fill one after the other, each for a few seconds. Then say which felt faster.

Two of the bars from Harrison and colleagues' 2007 experiment, each running for 5.5 seconds, timed by your browser's requestAnimationFrame. Nothing is loading: only the way the bar moves changes.

In the panel's code:

  • Line 4 works out the share of the 5.5 seconds that has gone.
  • Line 5 sets the bar's width from the bar's function, once every frame.
  • Line 7 notes how long the bar really took, once it's full.

Promise a little less than you deliver

A percent-done bar also sets an expectation. Once people see it move, they guess when it will finish. Maister's "First Law of Service" is a tiny formula about this: satisfaction equals perception minus expectation. How happy you are depends on what you got, compared with what you expected.

Real-life analogyThe waiter who says two minutes

You ask for the bill. One waiter says "two minutes" and is back in one. Another says "one minute" and takes two. The second waiter took longer, and he also broke his promise. The first one feels quick, because he beat his own.

In real life: "Two minutes"
In web performance: A time estimate, or how fast the bar moves early on
In real life: Back in one
In web performance: Finishing sooner than the bar or the estimate suggested
In real life: "One minute", then two
In web performance: A bar that races ahead, then stalls near the end
In real life: How you feel about the waiter
In web performance: How people feel about the wait: what they got, minus what they expected

Maister quotes a restaurant habit from a 1979 book on running services. Some restaurants tell guests the wait will be longer than they expect. Guests who agree are pleased to be seated early, and start the meal in a better mood. NN/g gives the same advice for progress bars:

  • Start the bar slowly and let it speed up toward the end. Then you don't set "a faster expectation than the system can maintain".
  • When you give a time, allow for extra. Finishing early is a pleasant surprise.
  • A bar that moves quickly, then hangs near the end, frustrates people. It cancels out the benefits of showing progress.

Why it matters: when you can't be sure how long the work will take, plan the bar for the slow case.

Optimistic UI

Now a different wait. Asha taps Add to cart, and the shop has to save her cart on its server. Lesson 2.1's doorbell changed the screen at once, with the button saying Adding…. Can the page go further, and show the tea in the cart before the server says yes?

Real-life analogyAn order on the board before the tin is checked

At Leaf and Kettle's counter, you ask for a mint tea. The woman behind the counter says "One mint tea!" and writes it on the board at once. She hasn't checked the tin yet, but there's nearly always mint. Once in a while the tin is empty. Then she comes over and says so: "Sorry, we're out of mint. Would you like jasmine?"

In real life: Writing the order on the board at once
In web performance: Showing the tea in the cart at once
In real life: Checking the tin
In web performance: Saving the cart on the server
In real life: There's nearly always mint
In web performance: The save nearly always works
In real life: Coming over to say sorry
In web performance: Taking the tea back out, with a clear message

That's an optimistic update. The page shows the result of an action at once, before the server confirms it. If the server says no, the page takes it back and says why. The UI, or user interface, is everything on the screen. So this is also called optimistic UI.

Here is the first half: showing the tea at once. In this run, the save works:

Show the tea before the savePop out in the code editor (opens in a new tab)JavaScript
let cart = 0; async function addToCart(save) {  cart += 1;  console.log(`Cart: ${cart}, saving…`);  await save();  console.log(`Cart: ${cart}, saved`);} addToCart(() => Promise.resolve());
  • Line 1 starts with an empty cart. Line 3 starts a function that adds a tea. Its save stands for saving the cart on the server. The word async lets the function pause partway through.
  • Line 4 adds the tea. Line 5 shows it at once: Cart: 1, saving….
  • Line 6 waits for the server. await pauses the function until the save is done. Then line 7 prints Cart: 1, saved.
  • Line 10 adds a tea. Promise.resolve() stands in for a server that says yes.

Asha sees her cart change straight away. The server's answer only changes the word after it. The second half is taking it back. In this run, the save fails:

Take the tea back if the save failsPop out in the code editor (opens in a new tab)JavaScript
let cart = 0; async function addToCart(save) {  cart += 1;  console.log(`Cart: ${cart}, saving…`);  try {    await save();    console.log(`Cart: ${cart}, saved`);  } catch {    cart -= 1;    console.log(`Cart: ${cart}, couldn't add it, try again`);  }} addToCart(() => Promise.reject(new Error("offline")));
  • Line 6 starts a try block: code that might fail. If the save on line 7 fails, the function jumps to the catch block on line 9.
  • Line 10 takes the tea back out. Line 11 says what happened.
  • Line 15 adds a tea. Promise.reject() stands in for a server that says no.
  • It prints Cart: 1, saving…, then Cart: 0, couldn't add it, try again.
Three rows from a click at 0 to 3 seconds, with the server answering at 1.5 seconds. Waiting for the server shows Cart: 0 until then, and Cart: 1 after. Optimistic and saved shows Cart: 1 from the click on. Optimistic and failed shows Cart: 1 at once, then Cart: 0 with a message to try again.Wait for the serverCart: 0Cart: 1Optimistic, savedCart: 1Cart: 1Optimistic, failedCart: 1Cart: 0, try againthe clickthe server answers
The server answers after 1.5 seconds in all three. Optimistic shows the result at once, and takes it back with a message if the save fails.

Use it when the action nearly always works and is easy to take back, like adding to a cart or liking a post. Don't use it where a wrong "yes" does harm: a payment should never look done before it is.

Why not just wait for the server? Because then nothing changes after the tap. NN/g warns that "most users will assume the action was not registered and they will try again". That's how double orders happen. Try all three ways, and make the server fail:

Playground: add to the cart three ways
What Add to cart runs, shortenedJavaScript
addButton.addEventListener("click", async () => {  if (mode === "optimistic") showCount(count + 1);  if (mode === "spinner") showSpinner();  try {    await server.save();    count += 1;    showCount(count);  } catch {    showCount(count);    showMessage("Couldn't add it. Try again.");  }});
The shopOptimistic

Cart: 0

No clicks yet.

Try it yourself
What changed, and when

Optimistic: the new count at once, taken back if the server fails. Press Add Mint tea to cart. The pretend server takes 1,500 ms to answer.

A demo with a pretend server: a timer answers after 1.5 seconds. The times are real, from your click: Event Timing for the next frame, rounded to 8 ms as the browser reports it (or requestAnimationFrame where it can't report), and requestAnimationFrame for when the cart changed.

In the panel's code:

  • Line 2: the optimistic way shows the new count before the save. Line 3: the spinner way shows a spinner instead.
  • Lines 6 and 7 count the tea once the server agrees.
  • Lines 9 and 10 show the real count again, with a message, if the save fails.

The panel times each click the way INP does. INP, or Interaction to Next Paint, is the Core Web Vital for responding to taps, clicks and key presses. It measures from the tap to the next frame the browser draws. That frame comes at about the same time in every way of adding, because INP doesn't wait for the server.

Why it matters: a page that shows nothing for 1.5 seconds can still have a good INP. A good INP doesn't prove people saw their tap work.

In React 19, the useOptimistic hook does the taking back for you. A hook is a function whose name starts with use, which lets a component use a React feature:

The same in React, shortenedJavaScript
import { startTransition, useOptimistic, useState } from "react"; function AddToCart({ saveToServer }) {  const [count, setCount] = useState(0);  const [shownCount, setShownCount] = useOptimistic(count);  const [error, setError] = useState(null);   function add() {    setError(null);    startTransition(async () => {      setShownCount((current) => current + 1);      try {        await saveToServer();        setCount((current) => current + 1);      } catch {        setError("Couldn't add it. Try again.");      }    });  }   return (    <>      <button onClick={add}>Add to cart</button>      <p>Cart: {shownCount}</p>      {error && <p role="alert">{error}</p>}    </>  );}
  • Line 5 makes an optimistic copy of the real count, shownCount, and line 24 shows it. Line 10 starts the work of adding, and line 11 adds one to the copy at once.
  • If the save on line 13 works, line 14 adds one to the real count. If it fails, the real count never changed. React shows it again by itself when the work ends, so line 16 only has to say why.

Showing useful content first

Back to the restaurant. The bread and water weren't a picture of food. They were food. To give Asha something real to read sooner, the page has to stop holding the tea back. Here is why it holds it back, with pretend answers as slow as the shop's server:

Wait for both answersPop out in the code editor (opens in a new tab)JavaScript
function answer(what, ms) {  return new Promise((resolve) => setTimeout(() => resolve(what), ms));}const started = Date.now();const seconds = () => Math.round((Date.now() - started) / 1000);const show = (what) => console.log(`${what} shown after ${seconds()} s`); const both = [answer("The tea", 1000), answer("The reviews", 3000)];Promise.all(both).then((answers) => answers.forEach(show));
  • Lines 1 to 3 make a pretend server. answer("The tea", 1000) gives back a promise: a value that will arrive later, here after 1,000 ms.
  • Lines 4 and 5 count the whole seconds since the wait started. Line 6 makes show, which prints what was shown, and when.
  • Line 8 asks for both answers at once. Line 9 uses Promise.all, which waits until every promise in the list has arrived. Then it shows them all.
  • It prints The tea shown after 3 s and The reviews shown after 3 s.

The tea was ready after 1 second, but it waited for the reviews. Promise.all is only as fast as its slowest answer. Now let each answer go on its own:

Show each answer as it arrivesPop out in the code editor (opens in a new tab)JavaScript
function answer(what, ms) {  return new Promise((resolve) => setTimeout(() => resolve(what), ms));}const started = Date.now();const seconds = () => Math.round((Date.now() - started) / 1000);const show = (what) => console.log(`${what} shown after ${seconds()} s`); answer("The tea", 1000).then(show);answer("The reviews", 3000).then(show);
  • Lines 1 to 6 are the same as before.
  • Line 8 shows the tea as soon as its answer arrives. Line 9 does the same for the reviews.
  • It prints The tea shown after 1 s, then The reviews shown after 3 s.

The reviews come no sooner, but the tea no longer waits for them. The recorded page's app.js makes the same change. The first three versions wait for both, on line 3:

app.js: wait for bothJavaScript
const getJson = (file) => fetch(file).then((response) => response.json()); Promise.all([getJson("tea.json"), getJson("reviews.json")]).then(([tea, reviews]) => {  showTea(tea);  showReviews(reviews);}); function showTea(tea) {  const section = document.querySelector("#tea");  section.innerHTML = `<h2>${tea.name}</h2><p id="about">${tea.about}</p><p class="price">${tea.price}</p>`;  section.removeAttribute("aria-busy");} function showReviews(reviews) {  const section = document.querySelector("#reviews");  section.innerHTML = `<ul>${reviews.map((review) => `<li>${review}</li>`).join("")}</ul>`;  section.removeAttribute("aria-busy");}

Lines 8 to 18 put each answer on the page. The fourth version, content first, changes only lines 3 and 4. The rest of the file is the same:

app.js: show each as it arrivesJavaScript
const getJson = (file) => fetch(file).then((response) => response.json()); getJson("tea.json").then(showTea);getJson("reviews.json").then(showReviews); function showTea(tea) {  const section = document.querySelector("#tea");  section.innerHTML = `<h2>${tea.name}</h2><p id="about">${tea.about}</p><p class="price">${tea.price}</p>`;  section.removeAttribute("aria-busy");} function showReviews(reviews) {  const section = document.querySelector("#reviews");  section.innerHTML = `<ul>${reviews.map((review) => `<li>${review}</li>`).join("")}</ul>`;  section.removeAttribute("aria-busy");}

Lines 3 and 4 still ask for both answers at once, but each one goes on the page as soon as it arrives. Here is its load, recorded the same way:

  1. Nothing drawn yet

    0 ms to 1,224 msA blank screen

  2. The same grey bars as the skeleton version.

    At 1,224 msA skeleton

  3. Mint tea, its description and price. The reviews are still grey bars.

    At 2,237 msThe tea: the LCP

  4. The three reviews have taken the place of their grey bars. Nothing has moved.

    At 4,229 msThe reviews

The content-first version, recorded in Chrome 156.
  • The tea showed at 2,236 ms. That's the LCP, 2,000 ms sooner than the skeleton version's.
  • The reviews came when the server sent them, as before. They just no longer held the tea back.
  • With the space kept, its CLS was 0, like the skeleton version's.

To look for yourself, download this load's trace: the full record of the load, which DevTools' Performance panel can open.

Of everything Ravi tried, this was the only change that moved the LCP, and the only one that showed real content sooner. It works for bigger pages too. Send the main content first, and let extras like reviews and chat boxes fill in later. The stage Architecture and frameworks shows how a server can even send the start of the HTML before its slow parts are ready.

When tricks backfire

Each of these tools can backfire when it's used the wrong way:

  • A spinner that flashes. For a wait under a second, NN/g calls a looping animation distracting. It's gone before people can tell what it was.
  • A spinner that never ends. Past about 10 seconds, people can't tell whether the page is still working or stuck. They may give up.
  • A placeholder of the wrong size. If the content needs more room than was kept, it still pushes things down. web.dev also warns that "Removing the space set aside for elements can cause just as much CLS as inserting content."
  • A bar that races, then stalls. That's the waiter who said one minute. The bar promised more than it kept.
  • An optimistic update that fails quietly. If the tea vanishes from the cart without a word, Asha may think it's still there. Always say what failed.
  • A wait added on purpose. Ryan Buell and Michael Norton found that people can value a site more when it shows its work, sometimes even over a site that answers at once. So show real work, like Checking stock. But never add a delay to look busy: if it holds back the content, LCP counts every millisecond of it.

The first problem has a common fix: show a spinner only once a wait has lasted a while. Here it is, with a pretend server:

Only show a spinner after a secondPop out in the code editor (opens in a new tab)JavaScript
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function load(serverMs) {  let spinner = false;  const timer = setTimeout(() => { spinner = true; }, 1000);  await wait(serverMs);  clearTimeout(timer);  console.log(`${serverMs} ms: ${spinner ? "showed a spinner" : "no spinner, just the result"}`);} load(300);load(2500);
  • Line 1 makes a pretend wait. Line 4 starts with no spinner.
  • Line 5 starts a timer that would show the spinner after 1 second.
  • Line 6 waits for the server. Then line 7 cancels the timer with clearTimeout. If the answer came first, the timer never fired.
  • Line 8 prints whether the spinner showed. Lines 11 and 12 try a 300 ms wait and a 2,500 ms wait. They print 300 ms: no spinner, just the result and 2500 ms: showed a spinner.

Why it matters: a fast answer gets the chance to arrive first. Only a wait that has already lasted a second gets a spinner.

Which feedback fits the wait?

Each wait needs its own kind of feedback. For each one, ask two things. How long is the wait? And is it one part of the page, a whole page, or an action that nearly always works?

Which feedback fits?
  • A menu that opens in 50 ms
  • Filtering a list already on the page, about 300 ms
  • A product page that takes about 3 seconds to arrive
  • Search results that take about 4 seconds
  • Uploading a 200 MB video
  • Exporting 50 invoices, one after another
  • Liking a post
  • Adding a tea to the cart
Try it yourself
0 of 8 correct

Sort each wait by the feedback that fits it best.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A skeleton makes the LCP faster." No. In the recordings, the skeleton's LCP was about the same as the blank page's. Only showing the tea sooner moved it.
  • "A skeleton stops layout shifts." Only if it keeps enough space. The space stops the shift, not the grey bars.
  • "Perceived performance is a trick instead of real speed." No. It's for the waits you can't remove yet. NN/g says skeleton screens "do not replace performance-optimization efforts".
  • "Every wait needs a spinner." No. Under a second, a spinner would only flash.
  • "Optimistic updates are always better." No. Use them for actions that nearly always work and are easy to take back. Never for a payment.
  • "A good INP means people got feedback." No. INP stops at the next frame, even if that frame shows nothing new.
  • "A time estimate should be as short as possible." No. Allow for extra time. Finishing early is a pleasant surprise.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA wait of exactly 10 seconds

Asha asks for a year of receipts, and the shop needs exactly 10 seconds to build them. What does this print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function feedbackFor(ms) {
  if (ms < 1000) return "nothing extra: just show the result";
  if (ms < 10000) return "a spinner, or a skeleton for a whole page";
  return "a percent-done bar";
}

for (const ms of [10000]) {
  console.log(`${ms} ms: ${feedbackFor(ms)}`);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upNine tenths of the way through

    Nine tenths of the time has gone. How full is the speeding-up bar? What does this print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const speedsUp = (x) => (x + (1 - x) / 2) ** 8;
    console.log(Math.round(speedsUp(0.9) * 100) + "%");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA failed save, then a good one

      The first save fails and the second one works. What does the last line print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      let cart = 0;
      
      async function addToCart(save) {
        cart += 1;
        console.log(`Cart: ${cart}, saving…`);
        try {
          await save();
          console.log(`Cart: ${cart}, saved`);
        } catch {
          cart -= 1;
          console.log(`Cart: ${cart}, couldn't add it, try again`);
        }
      }
      
      async function main() {
        await addToCart(() => Promise.reject(new Error("offline")));
        await addToCart(() => Promise.resolve());
      }
      main();

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA wait just over a second

        The answer takes 1,200 ms. What does this print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
        
        async function load(serverMs) {
          let spinner = false;
          const timer = setTimeout(() => { spinner = true; }, 1000);
          await wait(serverMs);
          clearTimeout(timer);
          console.log(`${serverMs} ms: ${spinner ? "showed a spinner" : "no spinner, just the result"}`);
        }
        
        load(1200);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind what moved, in DevTools

          The spinner version had one layout shift. Open it in DevTools: download its trace, or record a load of the page. Which elements did the shift move? Name them the way DevTools does.

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe skeleton NN/g advises against

            NN/g's article Skeleton Screens 101 describes three kinds of skeleton screen. It advises against one of them. Which kind?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask: does this change how the wait feels, how long it is, or what the numbers see?

              The perceived performance quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does the second line of the what-to-show example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function feedbackFor(ms) {
                  if (ms < 1000) return "nothing extra: just show the result";
                  if (ms < 10000) return "a spinner, or a skeleton for a whole page";
                  return "a percent-done bar";
                }
                
                for (const ms of [400, 3000, 15000]) {
                  console.log(`${ms} ms: ${feedbackFor(ms)}`);
                }

                Choose an answer to see the explanation.

              2. Question 2 of 8In the recorded loads, the spinner and skeleton versions reached their largest contentful paint at about the same moment as the blank one. Why?

                Choose an answer to see the explanation.

              3. Question 3 of 8What did the skeleton change in the recorded loads, compared with the blank and spinner versions?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does the last line of the two-adds example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                let cart = 0;
                
                async function addToCart(save) {
                  cart += 1;
                  console.log(`Cart: ${cart}, saving…`);
                  try {
                    await save();
                    console.log(`Cart: ${cart}, saved`);
                  } catch {
                    cart -= 1;
                    console.log(`Cart: ${cart}, couldn't add it, try again`);
                  }
                }
                
                async function main() {
                  await addToCart(() => Promise.resolve());
                  await addToCart(() => Promise.reject(new Error("offline")));
                }
                main();

                Choose an answer to see the explanation.

              5. Question 5 of 8In Harrison and colleagues' 2007 experiment, every bar took the same time. Which kind felt faster?

                Choose an answer to see the explanation.

              6. Question 6 of 8Which wait did Maister propose feels longest?

                Choose an answer to see the explanation.

              7. Question 7 of 8When does a spinner do more harm than good?

                Choose an answer to see the explanation.

              8. Question 8 of 8A report usually takes about 40 seconds to build. Following NN/g's advice, what should the page say while it waits?

                Choose an answer to see the explanation.

              Key takeaways

              • Perceived performance is how fast a page feels. Busy, explained, predictable waits feel shorter than empty ones.
              • Under 1 second, show nothing extra. Up to 10 seconds, a spinner or a skeleton. From 10 seconds on, a percent-done bar.
              • In the recordings, the spinner and the skeleton didn't move the LCP. The skeleton's kept space stopped the layout shift.
              • Progress bars that speed up at the end feel faster than ones that stall near it. Promise a little less than you deliver.
              • Optimistic updates show the result at once, and say clearly when they take it back.
              • Showing each part as it arrived brought the LCP forward by 2,000 ms. Only showing content sooner changes the numbers.

              Remember the one-liner.
              Make the wait shorter where you can. Where you can't, fill it, explain it, and keep its space.

              Sources

              The facts on this page come from this research and these docs:

              Coming next: From onload to user-centric metrics. It looks at why the load event stopped being enough, and how metrics came to measure what people actually see.

              CompleteFrontend Clear concepts. Working examples.