Skip to content
academia.sh

Lesson 19 / 22

Loading and Empty States

Splitting waiting time into feedback classes, the effect of indicator delay on flash rate, justifying the skeleton view with the layout shift score, and the three types of empty state.

Contents

The previous lesson defined a component’s five interaction states. All five rested on a single assumption: the component exists on screen and is ready for interaction. In the catalog interface this assumption is frequently false. The user pressed the search button and the results have not arrived yet; the search returned no records; or the user just opened the page and has not searched for anything at all.

In all three cases there is no result list to show on screen. What needs designing is not the list itself but its absence. This lesson separates two forms of absence: absence because the data is on its way, and absence because there is no data. The two communicate different information and call for different solutions.

Waiting Is a Range of Durations

The loading state is not a single design; it requires something different depending on how long it lasts. The duration itself is measured, and the decision is grounded in that measurement.

// waiting.mjs — distributing measured response times across feedback classes

// Reproducible sample generation: linear congruential generator + log-normal transform
function generator(seed) {
  let s = seed >>> 0;
  return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296);
}
const random = generator(20260727);
function normal() {
  const u1 = Math.max(random(), 1e-12), u2 = random();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Response time of the catalog search (ms): median 220 ms, right-tailed
const samples = Array.from({ length: 200 }, () =>
  Math.round(220 * Math.exp(0.9 * normal()))
).sort((a, b) => a - b);

const percentile = (p) => samples[Math.min(samples.length - 1, Math.floor((p / 100) * samples.length))];
console.log(`sample count: ${samples.length}`);
console.log(`min ${samples[0]} ms   median ${percentile(50)} ms   p90 ${percentile(90)} ms   p99 ${percentile(99)} ms   max ${samples[samples.length - 1]} ms`);

// Feedback classes: each class's ceiling and the display it requires
const CLASSES = [
  { name: "instant",     ceiling: 100,      display: "no feedback needed" },
  { name: "seamless",    ceiling: 1000,     display: "pointer/button state is enough" },
  { name: "noticeable",  ceiling: 10000,    display: "loading indicator" },
  { name: "long",        ceiling: Infinity, display: "progress + cancel" },
];

console.log("\nclass         ceiling(ms)  sample count  share    required display");
for (const c of CLASSES) {
  const floor = CLASSES[CLASSES.indexOf(c) - 1]?.ceiling ?? 0;
  const n = samples.filter((v) => v > floor && v <= c.ceiling).length;
  console.log(
    `${c.name.padEnd(13)} ${(c.ceiling === Infinity ? "-" : String(c.ceiling)).padStart(12)} ${String(n).padStart(13)}  ${(n / samples.length * 100).toFixed(1).padStart(5)}%   ${c.display}`
  );
}

// Indicator delay: how many requests trigger the indicator, and how many of those flash?
// Flash criterion: the indicator stays visible for less than 200 ms.
console.log("\ndelay(ms)  indicator opened  flashing  flash rate (all requests)");
for (const delay of [0, 150, 300, 500]) {
  const opened = samples.filter((v) => v > delay);
  const flashing = opened.filter((v) => v - delay < 200).length;
  console.log(
    `${String(delay).padStart(9)} ${String(opened.length).padStart(17)} ${String(flashing).padStart(9)} ${(flashing / samples.length * 100).toFixed(1).padStart(28)}%`
  );
}

// Minimum visible duration rule: once the indicator opens, it stays for at least 300 ms.
// This adds artificial delay to some requests; its total cost is computed here.
const DELAY = 300, MIN_VISIBLE = 300;
let artificial = 0, affected = 0;
for (const v of samples) {
  if (v <= DELAY) continue;
  const visible = v - DELAY;
  if (visible < MIN_VISIBLE) { artificial += MIN_VISIBLE - visible; affected++; }
}
console.log(`\n300 ms delay + 300 ms minimum visible duration:`);
console.log(`  requests with artificial delay added: ${affected} / ${samples.length}`);
console.log(`  total added duration: ${artificial} ms, average per request: ${(artificial / samples.length).toFixed(1)} ms`);
sample count: 200
min 23 ms   median 228 ms   p90 722 ms   p99 2100 ms   max 2362 ms

class         ceiling(ms)  sample count  share    required display
instant                100            45   22.5%   no feedback needed
seamless              1000           147   73.5%   pointer/button state is enough
noticeable           10000             8    4.0%   loading indicator
long                     -             0    0.0%   progress + cancel

delay(ms)  indicator opened  flashing  flash rate (all requests)
        0               200        89                         44.5%
      150               126        59                         29.5%
      300                75        34                         17.0%
      500                41        20                         10.0%

300 ms delay + 300 ms minimum visible duration:
  requests with artificial delay added: 47 / 200
  total added duration: 7573 ms, average per request: 37.9 ms

The class boundaries are established thresholds tied to human perception. A response under one hundred milliseconds is perceived as instant; a response of up to one second does not break flow but is noticeable; a wait of up to ten seconds causes attention to drift; beyond that is the range in which the user moves on to something else.

The measurement hands over a design decision directly: 96 percent of requests complete in under one second. That means the catalog search mostly does not need a loading indicator. If the indicator opens on every request, the indicator itself becomes a source of noise.

The second table turns this into a number. When the indicator opens with no delay, 44.5 percent of requests make it appear and vanish in under 200 milliseconds; an element that flashes onto the screen and disappears immediately draws attention instead of giving information. Pulling the delay to 300 milliseconds brings that rate down to 17 percent, and to 500 milliseconds brings it down to 10 percent.

Delay alone is not enough, because the indicator can still close right after it opens. The second rule is that once the indicator has opened, it stays visible for at least some minimum duration. The third block gives its cost: the 300-millisecond minimum-visible-duration rule adds artificial delay to 47 of 200 requests and produces an average cost of 37.9 milliseconds per request. This cost is paid deliberately: the price of eliminating flash is that some requests appear to take slightly longer.

The “progress + cancel” class in the fourth row has no samples at all. This means the catalog search never falls into that class; but if another operation in the interface — a bulk export, for instance — falls into that class, a progress indicator and a cancel button become mandatory. An indeterminate indicator does not tell the user, in an operation lasting more than ten seconds, whether it is progressing at all.

The Skeleton View Reserves Space

The loading indicator’s second job is to hold the place of the content that is coming. A small spinner does not do this job: when the content arrives, everything below it gets pushed down, and the point the user was looking at shifts.

This shift is measurable. The layout shift score is the product of the ratio between the area affected by the shifting elements and the viewport area, and the ratio between the distance the elements travel and the largest dimension of the viewport.

// skeleton.mjs — skeleton view row count and layout shift score

const VIEWPORT = { width: 1280, height: 800 };
const HEADER_Y = 96;      // top header strip
const ROW_Y = 72;         // result row height
const FOOTER_Y = 80;      // footer strip

// 1) How many skeleton rows fit the visible area?
const area = VIEWPORT.height - HEADER_Y;
console.log(`visible content area: ${area} px`);
console.log(`row height: ${ROW_Y} px`);
console.log(`fully fitting rows: ${Math.floor(area / ROW_Y)}`);
console.log(`partially visible row: ${(area % ROW_Y) > 0 ? "yes (" + (area % ROW_Y) + " px)" : "no"}`);

// 2) Layout shift score: impact fraction x distance fraction
// Unstable element: the footer strip. It shifts when going from the loading view to the loaded view.
function shiftScore(prevTop, nextTop) {
  const g = VIEWPORT.height;
  const visibleRange = (top) => {
    const bottom = Math.min(g, top + FOOTER_Y);
    return top >= g ? [0, 0] : [Math.max(0, top), bottom];
  };
  const [p1, p2] = visibleRange(prevTop);
  const [n1, n2] = visibleRange(nextTop);
  const unionTop = Math.min(p1 || Infinity, n1 || Infinity);
  const unionBottom = Math.max(p2, n2);
  const impactFraction = (unionBottom - unionTop) / g;
  const distance = Math.abs(nextTop - prevTop);
  const distanceFraction = distance / Math.max(VIEWPORT.width, VIEWPORT.height);
  return { impactFraction, distanceFraction, score: impactFraction * distanceFraction, distance };
}

const ROW_COUNT = 9; // visible row count in the loaded list
const loadedFooter = HEADER_Y + ROW_COUNT * ROW_Y;

const SCENARIOS = [
  { name: "spinner (120 px)", height: 120 },
  { name: "skeleton 5 rows", height: 5 * ROW_Y },
  { name: "skeleton 8 rows", height: 8 * ROW_Y },
  { name: "skeleton 9 rows", height: ROW_COUNT * ROW_Y },
];

console.log("\nloading view              footer before  footer after  distance  impact frac  distance frac  shift score");
for (const s of SCENARIOS) {
  const before = HEADER_Y + s.height;
  const k = shiftScore(before, loadedFooter);
  console.log(
    `${s.name.padEnd(25)} ${String(before).padStart(11)} ${String(loadedFooter).padStart(13)} ${String(k.distance).padStart(8)} ` +
      `${k.impactFraction.toFixed(4).padStart(11)} ${k.distanceFraction.toFixed(4).padStart(14)} ${k.score.toFixed(4).padStart(12)}`
  );
}
console.log("\naccepted good upper limit: 0.100");
visible content area: 704 px
row height: 72 px
fully fitting rows: 9
partially visible row: yes (56 px)

loading view              footer before  footer after  distance  impact frac  distance frac  shift score
spinner (120 px)                  216           744      528      0.7300         0.4125       0.3011
skeleton 5 rows                   456           744      288      0.4300         0.2250       0.0968
skeleton 8 rows                   672           744       72      0.1600         0.0563       0.0090
skeleton 9 rows                   744           744        0      0.0700         0.0000       0.0000

accepted good upper limit: 0.100

The spinner produces a score of 0.3011, three times the accepted good upper limit. The reason is that the indicator is much shorter than the actual content, so the footer gets pushed down 528 pixels when the content arrives.

The skeleton view’s row count determines this score directly. Five rows stay just under the limit at 0.0968, eight rows drop to 0.0090, and at nine rows the shift disappears completely. Since exactly nine rows fit the visible area, that is the correct number.

The rule that follows is: the skeleton mimics not the count of the incoming content but the row count the visible area holds. Even if the search will return thirty results, drawing thirty skeleton rows is unnecessary; the nine rows in the visible area are enough to eliminate the shift.

The skeleton view has a limit that gets overlooked: the skeleton commits to the structure of the incoming content. Drawing a nine-row skeleton and then having three results arrive makes the user believe, for a moment, that there were nine results. For this reason the skeleton is used only when it is certain results are coming; in a search where the result count has a high probability of being zero, an empty placeholder area is more appropriate than a skeleton.

An Empty State Is Not an Error

The state in which loading has finished but there is no content arises from three separate causes, and each one says something different.

First-use emptiness. The user just opened the catalog page and has not searched yet. What is missing here is not data but the user’s action. The right content is an explanation and a starting path: what the search covers and which fields it can search on.

No-results emptiness. The user searched, the system worked, and there is no matching record. What is missing here is data. The right content is to restate what the search was and to offer a way to loosen the narrowing: a list of the applied filters and the option to remove them one at a time. Restating the search text is not a detail but a requirement; it is the user’s only way to see that they typed something wrong.

Cleared-list emptiness. The user returned every book they had borrowed, and the “my borrowed items” list emptied out. Here the emptiness is a success. The right content is neither an explanation nor a suggestion; a single line stating the situation is enough.

The common mistake across all three states is presenting the emptiness as an error. Error color, a warning icon, and the phrase “not found” tell the user that something broke; yet in all three states the system worked correctly. The distinction from the Functional Colors lesson applies here: an empty state falls into the informational class, not the error class.

The second common mistake with empty states is leaving the screen entirely blank. In that case the user cannot tell whether loading is still in progress. An empty state is a design too, and it has to carry text; which text and how to write it is the question of this topic’s final lesson.

Summary

  • Waiting is not a single state but a range that needs different display depending on duration; the class boundaries are compared against measured durations.
  • If the measurement shows most requests complete under the indicator threshold, the indicator opens with a delay; the delay directly lowers the flash rate.
  • Once the indicator opens, it stays visible for at least some minimum duration; the cost is artificial delay added to some requests, and that cost can be computed.
  • The skeleton view reserves space and zeroes out layout shift; the correct row count is not the number of incoming results but the number of rows the visible area holds.
  • The skeleton commits to the structure of the incoming content; when the probability of no results is high, the skeleton is misleading.
  • An empty state arises from three separate causes, none of them an error; the emptiness is presented in the informational class and always carries text.

Next Step

An empty state was a result of the system working correctly. The states in which the system does not work correctly require a separate design: an invalid value was entered in the ISBN field, the borrow request could not reach the server, the user’s borrowing limit is full. These three cannot be presented in the same box, because each has a different path to recovery. The next lesson classifies errors by source and recoverability, determines where a message is placed by its distance from the error field, and discusses which errors should never be shown at all.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close