Skip to content
Web Performance

I Measured the Wrong Thing Three Times — What Animation Actually Costs a Reading Page

Swapping a JavaScript scroll reveal for the CSS one saved 16 ms of script per page scroll. Stopping eight animations that repeat forever saved nothing measurable. And the harness that found this told me a confident, wrong answer three times before it told me a true one.

Published · How this was researched

Back to Global Tech Search

Market Horizon

Point-in-time measurement, Aug 28 2026 — one machine, one browser, two live pages of this site

Target Sector

Web Performance, Core Web Vitals, Front-end Architecture, Publishing

CSS reveal vs JavaScript

−16 ms

script per full-page scroll — real, and small

Stopping 8 perpetual animations

no effect

not one metric separated from noise

Articles shipping invisible

27 of 27

found by accident; now 0

There are two claims sitting in this repository's own CSS, written by me, with nothing behind them. One says that handing an animation to animation-timeline: scroll() means it "costs nothing measurable". The other says the JavaScript alternative "ruins INP". Both are the received wisdom. Neither had a number.

This site has a rule about that — three posts ago it cost me a weekend of taking unbacked measurement claims out of published work. The rule applies to code comments too.

So I measured it. It took four harness designs, and the first three each gave me a confident answer that was wrong in a different way.

What was actually running

Twenty-two chart components on this site gated a fade-and-rise on framer-motion's useInView: an IntersectionObserver, a React state commit, and an animation driver, per element, to move opacity and translateY — two properties the compositor can move on its own. A further set of animations carried repeat: Infinity and simply never stopped.

Counting only components belonging to live, indexed posts — which took two attempts, see below — that is 25 scroll reveals and 56 perpetual animations across 11 of the 22 live posts that have charts.

Both are on the research plan's anti-pattern kill-list. Neither had been measured.

The measurement

Two A/B builds of this repository, measured identically: same page, same server, same 4x CPU throttle, five reps each. The only thing that differs between arms is the mechanism. Absolute numbers are properties of the page — recharts, hydration, the article itself — so only the difference says anything, which is why analyse-reveal.mjs refuses to report on a single build.

Interactions are issued during the scroll, because the question is whether a mechanism holds the main thread when input arrives; an interaction on an idle page would never see the contention.

Result one: the reveal, which is real and small

What the Reveal Mechanism Costs Across a Full-Page Scroll

cloud-repatriation-sovereign-workloads-2026, median of 5 reps, 4x CPU throttle — bars are the full per-rep range

Script across a whole page scroll falls from 77.0 ms to 60.8 ms, and style recalculation from 224.5 ms to 199.7 ms. Neither pair of ranges overlaps, so both survive the separation rule.

That is a genuine saving and it is worth taking, because it is free. It is also 16 milliseconds, spread across the entire act of scrolling an article top to bottom, on a machine deliberately slowed to four times its real cost. Unthrottled, my M1 cannot resolve the difference at all.

Nothing else moved. Idle cost, worst interaction latency, and bytes transferred were all unseparated:

framer-motionCSS view()separated?
Script during scroll77.0 ms60.8 msyes, 1.3x
Style recalc during scroll224.5 ms199.7 msyes, 1.1x
Main thread busy, idle1.25%1.23%no
Worst interaction96 ms88 msno
JavaScript transferred616.6 KB616.6 KBno

The bytes are the part worth dwelling on. Removing useInView from every chart component removes no dependency at all — framer-motion is still needed by the search modal and the card components, so the library ships either way. The intuition that "drop the animation library, drop the bundle" is simply not what happens when the library has more than one caller.

The received wisdom said this swap would fix INP. It moved INP by nothing at all, and moved script time by sixteen milliseconds.

Result two: the animations that never stop, which cost nothing

Eight Animations Repeating Forever, Against the Same Eight Stopped

global-tech-landscape-2026, median of 5 reps, 4x CPU throttle — every pair of ranges overlaps

I changed every repeat: Infinity on a live page to repeat: 0, rebuilt, and measured the same way.

Not one metric separated. Not idle cost, not the worst screen in a positional sweep, not scroll-phase script, not style recalc, not interaction latency, not long tasks. The medians wobble in both directions, which is what noise looks like.

This is the opposite of what the plan I was working from assumes, and the opposite of what I expected when I started. It has a mechanism: those animations move opacity, x, y and scale, and framer-motion hands those to the compositor, where the main thread never sees them.

The cost is real when the property cannot composite. On a different page — animating circle.cx, circle.cy and circle.r, which are SVG geometry and must be recalculated on the main thread every frame — I measured idle main-thread occupancy of 7.32% against 0.73%, a tenfold difference with no overlap between ranges. So the honest rule is not "decorative animation is expensive." It is:

That page, as it happens, is one nobody can read. Which is the third of my four mistakes.

The four wrong answers

Each of these produced a number. None of them announced itself.

One — I measured where nothing was running. The first harness idled at the top of the page and read main-thread busy time. It reported 0.01% for both arms and I nearly published "perpetual animation is free" on the strength of it. Every one of those animations is gated on animate={inView ? … : {}}. At scroll position zero, not a single one had started. The probe was pointed at a page where the thing under test did not yet exist.

The tell was that the two arms agreed exactly. Agreement between arms is what a broken probe produces, and it is indistinguishable from a real null unless you go looking for the mechanism you expected and fail to find it.

Two — I measured only one end of the page. Fixing that by idling after a full scroll found a large effect. But a perpetual animation costs the main thread only while its element is on screen — the browser does not spend style and layout on an SVG scrolled out of view. So idling at the bottom missed everything above it, and reported 0% for pages whose animation sits in the middle. The fix was a sweep: park at eight evenly spaced positions and measure each, because what a reader pays is the cost wherever they happen to stop.

Three — I measured a page nobody can read. The tenfold result came from radiative-cooling-space-2026, which is in this site's NOINDEXED_SLUGS: a demoted post, absent from the sitemap. The physics was fine. The claim "this site pays this" was not one that page could support. The same error had inflated my counts — 138 perpetual animations and 68 reveals across all components, against 56 and 25 across components belonging to live posts. I had been quoting the number that included the pages I had already decided readers should not see.

Four — I reported medians without ranges. Covered above; it is now enforced in the analyser rather than remembered.

What the measurement found that it was not looking for

Verifying that the CSS reveal degraded correctly, I checked what the server-rendered HTML looked like without JavaScript. The reveal I was replacing rendered style="opacity:0" into the static HTML and waited for React to hydrate before the content became visible.

Then I counted how many elements were like that across the site.

Content Shipped Invisible in the Static HTML of Live Posts

Counted from the built pages of all 27 live posts, before and after

All 27 live posts, 60 elements. And 27 of those 60 were one per post — because the wrapper around the entire article body in the post route was a motion div with initial={{ opacity: 0, y: 20 }}. Every article on this site was shipping its whole text inside an element styled invisible, waiting on JavaScript.

The text is in the DOM, so an extractor that ignores styling still reads it, and Googlebot renders JavaScript and would have seen the page normally. But anything that applies inline styles without executing scripts renders a blank article — and the plan this site is working from wants exactly those clients, the answer-engine crawlers, to be able to read and cite it. Trading that against a half-second fade-in is not a trade I would have made if anyone had put it to me in those words.

It is now zero of 27. The article wrapper is a plain div, and the CSS reveal animates from a visible default inside an @supports block, so a browser without scroll-driven animations — Firefox still ships them behind a flag — shows the content rather than a permanently transparent box.

An animation may enhance how content arrives. It may never decide whether it arrives.

That rule is now a comment in the stylesheet, which is where I should have put it before writing 60 elements that broke it.

What I changed, and what I did not

Shipped: the CSS reveal, applied by codemod to the 15 components where the transform is provably safe. It buys the 16 ms, and more importantly it is the version whose failure mode is visible content. One component is left for a human, because it has a paired motion div that is not a reveal and rewriting its closing tags would re-parent the wrong element — the codemod refuses rather than guesses.

Shipped: the article-body wrapper fix, which is the whole of the 27-post finding.

Not shipped: removal of the 56 perpetual animations. The plan's kill-list says to delete them. My own measurement says that on a live page, stopping them changes nothing I can detect. Deleting 56 animations across 11 posts is a visible change to how most of this site looks, and I am not making a visual change of that size on the strength of a null result. If they go, it should be because they are not earning their place editorially — which is a real argument, and a different one.

What this does not say

It does not say CSS animation is not faster than JavaScript animation. It says that on these two pages, for these two mechanisms, at 4x throttle, the difference is 16 ms of script and nothing else, and that a library with other callers does not leave the bundle when one caller stops using it.

It does not say perpetual animation is free. It says the cost tracks the property being animated and whether the element is on screen, and that a page animating compositable properties showed none of it while a page animating SVG geometry showed tenfold.

And it is a lab result. Synthetic input, one browser, one fanless M1 in Kathmandu, a local server with no network in the path. Chrome's field INP for real readers is a different number that I do not have.

The runs from the first two harness designs are not in that directory. They are the ones that measured the top of a page where nothing had started, and a post that readers cannot reach, and keeping them beside the corrected ones would put three incompatible measurement windows in one folder under the same field names. They are described in the README and nowhere else, which is the right amount of visibility for a measurement that was wrong.

Sources

  1. Reveal and perpetual-animation run logs — four runs, two live pages, five reps eachThis repository, 2026-08-28data/reveal/*.json — every figure in this post is computed from these by scripts/bench/analyse-reveal.mjs.
  2. What animation costs a reading page — dataset READMEThis repositorydata/reveal/README.md — the phase design, and the four things the harness measured wrong before it measured anything right.
  3. Event Timing API — PerformanceEventTiming.durationMDNThe mechanic INP is defined over, and the source of the 8 ms quantisation that limits the interaction figures here.
  4. CSS scroll-driven animations — animation-timelineMDNThe view() and scroll() timelines the CSS arm uses, and the browser-support position that makes the @supports fallback load-bearing.
  5. Scroll-driven animations, implementation statusMozillaStill open as of Aug 2026 — Firefox ships this behind layout.css.scroll-driven-animations.enabled, which is why the fallback must render visible content.

Advantages

  • Both experiments are A/B builds of this same repository measured identically, so the comparison controls for everything the page does that is not the mechanism under test — and the analyser refuses to report a delta from a single build
  • Every difference is reported with the full per-rep range, and only counted when the ranges do not overlap; the pairs that merely have different medians are printed under a heading that says they are not separated
  • The harness's own three wrong answers are published with the right one, because each was silent, plausible, and would have been publishable

× Challenges

  • Lab measurements on one fanless M1 under one browser, with synthetic input against a local server — these are not the INP figures Chrome reports from real users, and nothing here is a Core Web Vitals result
  • Two pages. The perpetual-animation null result holds for a page whose animations move compositable properties; a page whose animations move SVG geometry behaved very differently, and only one such page was measured
  • The reveal saving is real but small — 16 ms of script across an entire page scroll, under 4x CPU throttling, which is a magnification of a difference an unthrottled M1 cannot resolve at all

Risk Assessment

A performance harness reports a number whether or not it looked in the right place, and a null result is the easiest thing in the world to manufacture by accident. This one produced 0.01% main-thread busy for both arms of a comparison — because every animation under test was gated on entering the viewport and the measurement window sat at the top of the page, where none had started. The run completed, the reps were tight, the arms agreed. Agreement between arms is exactly what a broken probe produces, and it is indistinguishable from a real null unless you go looking for the mechanism you expected to see and fail to find it.

Abhishek Kushwaha

Written by Abhishek Kushwaha

Full-stack software engineer in Kathmandu, Nepal — six years shipping production Django and Next.js systems, most recently at Pinakin Technologies & Research Center. Writes Global Tech Search on what the AI buildout costs in power, water and silicon, measuring it first-hand where he can. More about the author →