tests/golden/scroll-trace.json · 2026-08-20
Does smooth scroll hurt performance?
The question is nearly always asked about the wrong quantity. Frame time during a scroll is what people profile, and on this repository’s two targets it went the other way: the Lenis-driven candidate ran a 1,200 px wheel burst at 8.54 ms mean frame while the un-refactored reference ran the same burst at 49.17 ms. The reference paints twelve canvases and a WebGL scene; the candidate on the day that trace was taken painted none. The difference is the page, not the lerp.
The cost that does belong to a smooth scroller is residency — what the page keeps asking the browser to do after the visitor has stopped. That reduces to one number, rAF callbacks per second while the page is parked and nothing is touched, and the instrument fits in a sentence: the trace tool wraps window.requestAnimationFrame with a counter that forwards to the native implementation, waits three thousand milliseconds, and divides by three.
The artefact those figures come out of was committed on 2026-08-20 and carries a null capture timestamp, so the commit date is the only date this article can honestly give. That matters twice over, because the candidate it measured had no GSAP, no WebGL and no canvases — which is half of why its frame time reads the way it does.
- 8.54 ms mean frame, candidate during the wheel burst
- 49.17 ms mean frame, reference twelve canvases and a WebGL scene
- 134.3 rAF calls per second at rest, reference parked mid-document, untouched
- 0 rAF calls per second at rest, candidate same probe, same run, gated smoother
Neither frame-time figure is a product measurement and the repository is blunt about it: the golden tooling runs under a forced software renderer, where non-GL routes float around 120 Hz and GL routes around 16 Hz, and the Hz figures are an artefact of that tooling and must never be quoted as product performance. What survives the caveat is the comparison — two targets, one machine, one method.
src/runtime/scroll/lenis-options.js:26-141
What does the configuration actually predict?
The whole scroll configuration on this site is eight stated options in one frozen object, each carrying a citation on its own line, plus a ten-row table of options deliberately not passed — exported so a test can assert the negative rather than trusting a comment to stay true. The eight are lerp 0.1, smoothWheel true, syncTouch false, touchMultiplier 1.25, autoResize true, autoRaf false, anchors false and infinite false.
The rule governing the split is written at the top of that file and it is a claim about claims: an option the legacy site inherits as a library default is inherited here too rather than restated, because writing syncTouchLerp 0.075 would freeze a value the pinned version owns. Restating a default converts we ship the behaviour of Lenis 1.1.20 into we ship our reading of the behaviour of Lenis 1.1.20, which is a different and worse claim. Three values are stated anyway — autoRaf, anchors and infinite — because each is a decision rather than an acceptance.
lerp 0.1 is what makes the feel arithmetic instead of opinion. The library damps by one minus the exponential of minus lerp times sixty times the frame delta, so the curve is one minus e to the minus six t and the milestones fall out of it: half the distance at ln 2 over six, or 116 ms, ninety per cent at 384 ms, ninety-nine per cent at 767 ms. Those are predictions made before the trace ran.
| Milestone | lerp 0.1 predicts | Candidate | Reference |
|---|---|---|---|
| 50 per cent of the distance | 116 ms | 141 ms | 275 ms |
| 90 per cent | 384 ms | 408 ms | 559 ms |
| 99 per cent | 767 ms | 766 ms | 950 ms |
| Mean frame during the burst | not predicted | 8.54 ms | 49.17 ms |
| Final position after 1,200 px | not predicted | 1,197 px | 1,200 px |
The candidate lands 25 ms behind theory at the halfway mark and one millisecond ahead of it at ninety-nine per cent. The reference is slower at every milestone while running the identical lerp, which is the clearest thing in the trace: what a visitor feels is what the page paints, not the smoothing curve. Compare the two in frames instead of milliseconds and that same identical curve reports as a fourfold difference, in the wrong direction — which is why the trace tool records frame counts and peak pixels per frame without ever comparing them across targets.
a15 §4.3 passivity census · 433 registrations
How many listeners can actually block a scroll?
Make your scroll listeners passive is the advice everyone repeats, and on the scroll event it is cargo cult. The scroll event is a generic Event and is not cancelable — there is no default action to prevent — so a non-passive scroll listener blocks precisely nothing. Passivity changes browser behaviour only for events that would have stopped the scroll had they been cancelled: touchstart, touchmove, wheel and mousewheel.
The legacy site audited here registers eleven scroll listeners on its home page and four of them are reported non-passive. Not one of the four was registered with passive false. They are default-passivity registrations — a bare boolean or an absent third argument — and even if they had been explicit it would not have mattered. The registrations that genuinely gate a scroll are elsewhere on that page, and there are eight of them.
The footprint of the smoother itself is smaller than the folklore and readable in one constant. The pinned source hard-codes a single listener options object with passive false and uses it for exactly four registrations — wheel, touchstart, touchmove and touchend on the root element — and those have to be non-passive, because calling preventDefault on the wheel event is how the smoothing exists at all. A fifth listener, scroll on the wrapper, is registered with a bare false and its handler contains no preventDefault call of any kind. Every audit tool counts it. Nothing it does can block.
The rebuild probe reports six scroll, wheel and touch listeners with four non-passive, against thirty-nine and twenty-seven on the reference. Both non-passive figures are ceilings rather than counts and the repository says so in its own caveats: the probe reads passivity only from whether the third argument is an object, so a bare boolean is tallied non-passive even where the browser applies its passive-by-default rule. Twenty-seven is an upper bound on the reference; eight is the defensible number.
Which brings up the most useful thing on this page, and it is a defect in this repository’s own prose. Six files here state that two of the legacy non-passive scroll handlers cost about 1,470 milliseconds. They do not. 1,469.8 is the millisecond at which the last of the eleven listeners was registered, read out of a table whose first column is headed t in milliseconds — a registration timestamp, not a cost, and no cost measurement of those handlers exists anywhere in the corpus. The engineering decision attached to the figure is still right, because those handlers prevent nothing and ought to be passive. The figure attached to the decision measures nothing.
scroll-controller.js:53-70, :505-522
Why does the smoother sleep, and what broke when it learned to?
Lenis 1.1.20 does not unsubscribe itself at rest. Left alone it takes a frame every sixteen milliseconds to interpolate a position that has stopped moving, which is exactly the residency the measurement above is about. Two pieces remove it: the frame clock refuses to schedule a frame when nothing runnable is subscribed, and the scroll owner opens its own subscription on input and closes it twelve stationary frames after the position settles.
Both constants are declared chosen rather than measured, in the code itself, and the honesty is the point. 0.05 px is below the smallest movement any consumer on this site can act on, and twelve frames is roughly two hundred milliseconds at 60 Hz — comfortably past the tail of a lerp 0.1 animation, which loses about eighty-nine per cent of its remaining distance every twenty-two frames. The file states that both become measured numbers the day the first scroll-feel trace is recorded.
- At rest, no subscription at all A freshly booted page holds no frame, deliberately: there is nothing to smooth until someone moves, and waking at boot would burn a dozen frames proving the position is still zero. A test asserts the clock is empty after init.
- Input opens the gate Wheel, touchstart, keydown and resize, all registered passive, plus the library’s own scroll emitter — which fires for the scrolls it did not cause, so a scrollbar drag, a keyboard or the End key wakes it too.
- Re-anchor the library clock Set its stored time back to zero before the first frame runs. Skip this and the smoother is handed a delta the length of the pause, and a smoothed gesture lands as an instant jump.
- Subscribe in two phases Index zero of the read phase advances the smoother; the compute phase publishes the position and runs the gate. The legacy buys that ordering with a boolean flag on the animation ticker. Here it is structural, because a flag can be forgotten and a phase cannot.
- Measure each frame Busy means the library reports itself scrolling or the position moved more than 0.05 px. Any busy frame resets the idle counter to zero.
- Sleep after twelve idle frames Both subscriptions close. The clock then stops scheduling on its own, because its count of live subscribers has reached zero and its scheduler returns early.
Step three exists because closing the subscription broke the feel, and the break was measured before the fix. The library computes its delta as the current timestamp minus the one it stored last frame, and while the subscription sleeps nothing advances that stored timestamp — so the first frame after a wake hands the damping function however long the visitor sat still. At a twenty-second pause the exponential completes in a single frame. Measured before the one-line fix: a 1,200 px wheel arrived in four frames at a peak of 1,200 px per frame, which is not smoothing at all.
The fix sets the library stored time back to zero before the first frame after a wake, and a unit test pins it: the first frame advances exactly zero pixels and the second advances about 114 px of the 1,200. The comment above that line is the thesis of this whole article in one sentence — a library that owns its own loop never has this problem because it never sleeps, and never sleeping is the cost this gate exists to avoid.
Anchor navigation is the other half of owning the scroll position, and it is where most smooth-scroll integrations quietly break the keyboard — smooth scroll, focus and history.
src/vendor/lenis/lenis.mjs:585-601 · P11-04
What does turning touch smoothing off give back?
syncTouch was flipped from true to false on this site today, and the reason is a defect rather than a preference. The reported symptom was that the page looked cropped at the top and the bottom on iOS Safari, and that the browser chrome behaved unlike every other site. The obvious fix — change the viewport units — would have treated the symptom and left the cause running.
The library half of the diagnosis is verifiable line by line in the pinned file, which is committed as bytes with a SHA-256 the build checks. With syncTouch true a touch event makes the smoothing branch true, the handler calls preventDefault, and the library drives the position itself. Every touchmove on a phone is cancelled.
204 var listenerOptions = { passive: false };
211 el.addEventListener('wheel', onWheel, listenerOptions);
212 el.addEventListener('touchstart', onTouchStart, listenerOptions);
217 el.addEventListener('touchmove', onTouchMove, listenerOptions);
222 el.addEventListener('touchend', onTouchEnd, listenerOptions);
448 wrapper.addEventListener('scroll', onNativeScroll, false);
// third argument is the capture boolean, so passivity is default —
// and onNativeScroll (:631-660) has no preventDefault in it at all
585 const isSmooth = options.syncTouch && isTouch
|| options.smoothWheel && isWheel;
586 if (!isSmooth) {
587 this.isScrolling = 'native';
588 this.animate.stop();
589 event.lenisStopPropagation = true;
590 return;
591 }
601 event.preventDefault(); // reached only when isSmooth is true
What a cancelled touchmove then costs is this repository diagnosis and should be read as one. Safari retracts its address bar in response to a user scroll gesture; a programmatic scroll is not one; so the bar never retracts, and every box sized in vh is sized to the large viewport — a height the page only has once the bar has gone. No primary Apple or WebKit statement of that rule turned up. The mechanism inside the library is readable in the source; the browser behaviour it depends on is reported, widely observed and undocumented by the vendor.
With syncTouch false the same code takes the branch at line 586, marks the scroll native, stops the animation and returns without preventing anything. The platform gets its momentum, its rubber band, its address bar and its accessibility settings back, and wheel smoothing is untouched because smoothWheel is a separate option and is still true. Note what does not change: those four registrations are still non-passive. What changes is whether preventDefault is ever called.
One thing had to be rebuilt by hand afterwards. On a phone the projects rail is a sticky, scroll-scrubbed track that reads as something you swipe and was not one, so a sideways finger did nothing at all. The replacement is the only explicitly non-passive listener this site adds outside the smoother: a touchmove handler that prevents only while a horizontal axis latch is engaged, past eight pixels of slop and inside a cone of about thirty degrees — and it moves the page rather than the rail, so there are never two owners on one visual position.
The rail that gesture drives sits on the projects index, where the same measurement discipline is applied to ten builds.
src/vendor/gsap/ScrollTrigger.js:61-64
If the smoother holds no frame, what is still running?
The zero at rest is true of the build it was measured on, and that build is ninety-five minutes older than the day GSAP was vendored into this tree. On the page as it stands the honest number is not zero. It is one, and the one does not belong to the smoother.
ScrollTrigger starts an empty self-rescheduling requestAnimationFrame when it is enabled and runs it for the life of the page, with the library’s own comment attached saying it exists because screen repaints were not consistent in some browsers unless something was queued. There is no option to disable it. Beside it a setInterval runs four times a second. Removing Lenis would remove neither of them.
- ScrollTrigger, the repaint loop
- An empty function that returns a fresh requestAnimationFrame for itself, started inside ScrollTrigger.enable and never stopped. The library ships no flag for it.
- ScrollTrigger, the sync interval
- A setInterval at 250 ms — four times a second for the life of the page, at ScrollTrigger.js:2110.
- The frame clock
- Nothing. Its scheduler returns early when the count of live subscribers is zero, which is why a fully degraded page also stops rather than idling politely.
- The scroll owner
- Nothing, twelve stationary frames after the last movement — and nothing at all on a page nobody has touched yet, asserted by a test that checks the clock is empty after init.
- The budget that holds the line
- At most one vendored callback per frame with every stage-13 system mounted, recorded in the rest gate’s own source as a per-frame figure of 1.15 or less.
This is also why the rebuild does not claim one frame loop. It declares two frame sources with a stated relationship: the frame clock, which owns every first-party rAF, and the GSAP ticker, which owns the animation library’s own advance and keeps it. GSAP is not forced onto the clock, and that is a decision rather than an omission — creating a tween wakes the ticker from inside the library, so sleeping it does not hold, and the function that would drive it externally is not public API.
What stops the second source becoming a permanent second loop is three asserted properties rather than a promise: the ticker’s own autoSleep, the fact that ScrollTrigger adds a ticker listener only on touch Safari, and a rest budget measured at the point on the page where the most systems are gated live at once. The point of naming all this is that the answer to does smooth scroll hurt performance, on this page, is that the smoother holds nothing and something else does.
ADR-008 · alternatives considered
Would native scroll or a transform smoother have been cheaper?
The property that decided this is one sentence long: the smoothed value is the document scroll offset. Because the library moves the real window, every getBoundingClientRect read on the page and every sticky pin inherits the smoothing for free, and ten independent readers agree without being told to. Measured, window.scrollY equalled the library reported scroll at all twenty-seven checkpoints across three routes, with velocity zero at every one.
Every alternative that smooths something else has to reconstruct that agreement explicitly, in every consumer, forever. That is the axis the table below is sorted on — mechanics, not taste.
| Approach | What it moves | Measured or documented |
|---|---|---|
| CSS scroll-behavior smooth | Fragment navigation and the CSSOM scrolling APIs only; user scrolls are untouched and the curve is user-agent defined | Baseline since March 2022 |
| CSS scroll-driven animations | Binds an animation to a scroll position. A different feature, not a wheel lerp | 85.43 per cent global support |
| GSAP ScrollSmoother | A transform on a wrapper content via matrix3d, which creates a new containing block and redefines every rect read on the page | 3 defensive strings in the legacy bundle, no plugin |
| Locomotive Scroll v5 | Dropped its own virtual-scroll container, rebuilt on Lenis and deleted its custom sticky implementation | v5 migration guide |
| Lenis on the real offset | The document scroll offset itself, which every consumer already reads | 27 of 27 checkpoints agree |
Writing your own eighty-line damping loop is the remaining option and it is cheaper only until the second week. What it gives up is the composed-path opt-out, reconciliation with the native scrolls the library still receives — a scrollbar drag, a keyboard, Home and End — the rubber-banding at the document ends, and a state machine the rest of the page reads.
The reduced-motion path is where the choice gets proved rather than argued. Under prefers-reduced-motion reduce this site never constructs a smoother at all — not built and destroyed, not stopped — and the one scroll listener installed in that mode is explicitly passive, because its handler never calls preventDefault and registering it otherwise would be pure main-thread cost. The trace reads the root element classes at six viewports and finds the smoother class at none of them.
That gate exists because the legacy reduced-motion path was a measured disaster. It honours the preference by polling six hundred frames for a global and then destroying the instance, because the obvious call — stopping the smoother — was measured to leave the page completely unscrollable: the wheel moved it zero pixels and the End key moved it zero pixels. The rebuild proves the opposite by wheel and by End at all six viewports, and at each one the End key reaches the document’s own bottom exactly.
Reduced motion is a mode rather than a switch, and the canvas half of that argument is what reduce means to a renderer.
tools/golden/scroll-trace.mjs, rest.mjs
How would you measure this on your own site?
Two commands produce everything above. The first drives a 1,200 px wheel burst on both targets and records the envelope in milliseconds, the reduced-motion regression at six viewports, the rest cost, the anchor behaviour and the listener passivity. The second walks eleven scroll stops, polls the frame clock until its live subscriber count reaches zero, and checks the result against a closed list of the sections allowed to hold a frame.
The polling matters more than it sounds. A fixed wait measures whatever happens to be in flight: the footer logo diagram is a 2.8-second build, so a 1.8-second settle catches it mid-flight and reports a frame that is an animation rather than a leak. Polling to idle separates a system that never lets go from a system that is still finishing. The same gate once caught itself — a check kept passing because the page was slow, and started failing when the page got faster and re-armed inside a window it used to miss.
- Milliseconds, never frames
- The two targets do not share a frame rate and the damping is frame-rate independent by construction. Comparing frame counts across them reports one identical curve as a fourfold difference, in the wrong direction.
- Non-passive is an upper bound
- The probe reads passivity only from whether the third argument is an object, so it overcounts. Twenty-seven on the reference is a ceiling, not a count of blockers; the defensible figure is the eight explicit registrations in the audit census.
- Rest cost needs three runs
- Three identical runs of the same legacy page produced 67, 61 and 61 rAF calls in roughly one-second windows. Any rest comparison needs the spread. The 134.3 above came from a different instrument under a different condition and does not correct those.
- Declared is not enforced
- Four candidate budgets sit in the thresholds file — rest callbacks at five per second, distinct rAF call sites at two, leaked globals at one, console errors at zero — and grepping the tools directory for their name returns nothing. They are declared and read by no check.
- What was never measured
- Nothing below 992 px has been measured on either target, and neither corpus holds an INP, CLS, long-task or LCP figure, because the probe installs no PerformanceObserver of any kind. The 200 ms INP threshold in the sources below is the standard, not a reading from this site.
Every instrument has a noise floor of its own, and measuring that floor is its own problem — the noise floor of a visual regression suite.
The engineering half of how this site is built is set out under the four disciplines.