← All notes
Frontend Development7 min

Server-render the truth, let the animation borrow it

If an animation is the only path that writes a value, every path where the animation does not run ships a lie. Our homepage counters SSR'd as zero and only became real after a scroll animation — so reduced motion, background tabs, and no-JS left zeros that look like claims. Render the real number first; let the animation borrow the display.

Three ways the animation does not run

The first is reduced motion. The component checked prefers-reduced-motion and skipped the animation, correctly — except that skipping the animation also skipped the only code path that ever set the number. Anyone with reduced motion enabled saw zeros. The media query also resolved after mount, so there was a flash of the wrong value for everybody else.

The second is a backgrounded tab. requestAnimationFrame does not fire in a tab that is not visible. Open the site in a background tab, which is exactly what happens when someone middle-clicks a search result or a link in Slack, come back a minute later, and the counters have never started.

The third is anything that stops JavaScript from running: a chunk that fails to load, a content blocker with an aggressive rule, a browser we did not test. All of them produce the same outcome, and none of them look like an error. The page renders. The numbers are simply wrong, in a way that specifically undermines a page whose job is to say what we have shipped.

Zeros are worse than no numbers

If the markup had been empty, the failure would announce itself. A zero is a plausible number. It reads as a claim: we have shipped zero projects. Nobody looking at it concludes the animation broke. They conclude we are new.

It is also what crawlers see. Server-rendered HTML containing zeros is what goes into a search index and what gets pulled into link previews. The animation is irrelevant to any consumer that does not run a full browser, and a lot of consumers do not.

We caught it by viewing source, not by looking at the page, which is its own lesson. The rendered page looked fine on every machine we casually tested, because we all had motion enabled and the tab in front of us.

Where the real value lives
Animate toward the truth
  • Server renders 0
  • Animation replaces it with the real number
  • Reduced motion resolving late leaves 0
  • A backgrounded tab pauses rAF and leaves 0
  • Crawlers read 0
Render the truth, animate over it
  • Server renders the real number
  • Animation borrows the display while it runs
  • Any interruption lands on the real value
  • No JavaScript still shows the truth

The rule generalizes: whatever you render on the server has to be true on its own.

// The real value is the initial state and the guaranteed end state.
// The animation only borrows the display while it runs.
const [display, setDisplay] = useState(value)

useEffect(() => {
  if (!inView || reduced) return
  const stop = count.on("change", (n) => setDisplay(Math.round(n)))
  const controls = animate(count, value, { duration: 1.4 })
  // However it ends -- finished, interrupted, tab hidden -- land on the truth.
  controls.then(() => setDisplay(value), () => setDisplay(value))
  return () => { controls.stop(); stop(); setDisplay(value) }
}, [inView, reduced, value, count])
Starting at 0 and animating toward the number is the bug. This starts at the number.

Render the number, let the animation borrow the display

The fix inverts the relationship. The component renders the real value in its markup. The animation, when it runs, temporarily overwrites the displayed text and walks it up to the value that was already there. The number is the source of truth. The animation is a decoration applied on top of it.

Concretely: the server output contains the real figure, not a zero. On mount, if animation is appropriate, the component sets the display to zero and counts up from there. If anything prevents that — reduced motion, background tab, no JavaScript at all — the correct number was already on screen and simply stays.

This also removes the flash. There is no window in which the DOM contains a value we do not believe, because the initial render is correct and every subsequent state is either correct or a deliberate transient on the way back to correct.

The general rule

Any time an animation is the only path that produces a value, you have a correctness bug waiting for a browser that does not cooperate. Animations should transform something that is already right. They should never construct it.

The same shape shows up in reveal-on-scroll. Elements start at zero opacity and an intersection observer fades them in. If the observer never fires — and observers behave unevenly for elements already in view, or inside a container that never scrolls — the content stays invisible. The content being present in the DOM makes it worse, not better: it is crawlable, it is in the accessibility tree, and it is not on the screen.

The disciplined version of the rule is that the no-JavaScript state of the page should be the correct state. Every animation is then a modification applied on top, and every failure to apply one degrades to something true rather than to something false.

Checking for it takes thirty seconds

View source, or use curl, and read the actual HTML the server produced. Not the inspector. The inspector shows you the DOM after JavaScript has run, which is precisely the view that hides this entire class of bug.

Then turn on reduced motion at the OS level and reload. Then open the page in a background tab, wait, and switch to it. Three checks, no tooling required, and between them they cover the paths that matter.

We now do the first one as a matter of course on any page with animated content, because the failure is invisible from every other angle and cheap to see from that one.

The same bug wearing other clothes

Once the shape is in your head you start seeing it everywhere. A skeleton loader that renders until data arrives is correct right up until the fetch fails silently, at which point the skeleton becomes the permanent state of the page — a loading indicator that has turned into a lie. Dates formatted only on the client render as empty or as raw ISO strings in the server HTML. A theme read from localStorage after mount gives a flash of the wrong palette, which is the same bug with a shorter duration.

The common root is treating the browser as the only renderer. Server output gets thought of as a rough draft that JavaScript will correct, rather than as something real people and real crawlers consume exactly as it is. Every value that only exists after hydration is a value that does not exist for some fraction of your traffic.

The honest counterpoint: some values genuinely cannot be server-rendered. Anything personalized, anything from localStorage, anything that depends on viewport size. For those the right move is not to fake it but to render a state that is true — a signed-out header rather than a blank one, a default theme rather than a flash — and let the client refine it. The rule is not that everything must be server-rendered. It is that whatever you do render must be true.

Questions

Why are animated zeros worse than empty markup?
Zero is a plausible statistic. Readers and crawlers treat it as "shipped zero projects," not as "animation failed." View source or curl the HTML — not the inspector after hydration — to see what you actually published.
How should count-up animations relate to server HTML?
Server-render the true figure. On mount, if motion is appropriate, temporarily show zero and count up to the value already in the markup. If reduced motion, a backgrounded tab, or missing JS blocks the animation, the correct number was already on screen.
What other UI patterns share this bug shape?
Reveal-on-scroll that starts at opacity zero, skeleton loaders that never clear after a silent fetch failure, and client-only date formatting that leaves empty or raw ISO strings in the SSR HTML. Animations should decorate a correct baseline, not construct it.

Sources

  1. MDN — prefers-reduced-motion
  2. MDN — Page Visibility API
  3. web.dev — Rendering on the web
  4. Next.js — Server Components

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us