Skip to the article

Motion Published Reading time 12 min

Smooth scroll measured: the cost is residency, not frame time

Frame time during a scroll is the wrong axis, and the numbers here go the other way. What a smooth scroller can genuinely cost is residency — the frames a page keeps scheduling once nobody is touching it — and on this site the last resident frame turned out not to belong to the smoother at all.

In short

Measured on this repository’s two targets, smooth scroll cost is not frame time during a scroll: the Lenis-driven candidate ran a 1,200 px wheel burst at 8.54 ms mean frame and the un-refactored reference ran the same burst at 49.17 ms, and that gap is what else the page painted rather than the lerp. The cost that is attributable is residency — rAF callbacks a page schedules while nobody is touching it, 134.3 per second on the reference against 0 on the candidate committed on 2026-08-20 — plus the listeners that can genuinely block a scroll, eight explicit non-passive registrations on the legacy home against four on the rebuild, all four of them the library’s own. Both are removable without giving up the feel. The sharpening: once the smoother was gated to zero, the frame still resident belonged to GSAP ScrollTrigger, not to Lenis.

What to take away

  1. Measure residency, not frame time: the number that separates a well-integrated smooth scroller from a badly integrated one is how many rAF callbacks the page schedules while nobody is touching it, 134.3 per second against 0 here.
  2. The scroll event is not cancelable, so its passivity flag is inert — only touchstart, touchmove, wheel and mousewheel can gate a scroll, and on the legacy home exactly eight registrations do.
  3. Smooth the real document offset rather than a transformed wrapper, because that is what lets every getBoundingClientRect read and every sticky pin on the page inherit the smoothing for free.
  4. A gate that closes has to re-anchor the library clock when it reopens: Lenis damps against a delta it stores itself, and after a twenty-second pause that delta completes the whole animation in one frame.
  5. The last resident frame on this page belongs to the animation library, not the smoother — removing Lenis would not remove ScrollTrigger self-rescheduling rAF, which ships with no option to disable it.
  6. Write the provenance beside the number: a figure quoted in six files here as a listener cost turned out to be a registration timestamp read out of the wrong column.

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
A 1,200 px wheel burst, then three seconds of stillness, on both targets. Read out of tests/golden/scroll-trace.json, committed 2026-08-20.

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.

The same 1,200 px wheel burst on both targets, against what lerp 0.1 predicts. Milestones are in milliseconds because the two targets do not share a frame rate.
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.

touchstart 4
touchmove 3
wheel 1
scroll 0
Explicit passive false registrations on the legacy home page, by event type. The zero on the scroll row is the whole argument.docs/evidence/legacy-forensics/a15-template-deps-perf.md:896-918, over 433 total listener registrations on the home route.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
The idle gate as it runs, from a page nobody has touched to a page that has gone quiet again. src/runtime/scroll/scroll-controller.js.

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.

The branch the whole touch decision turns on, read out of the pinned library at src/vendor/lenis/lenis.mjs. Line numbers are the file’s own.
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.
What holds a frame on this page once the visitor stops, and what each of them is.

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.

The alternatives, decided on mechanics. The last column is what each claim is measured or documented against.
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.
The caveats that make these numbers honest rather than impressive. Each one is recorded in the repository beside the figure it qualifies.

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.

Questions

How many non-passive listeners does Lenis install?

Four, and they are not optional. One hard-coded options object with passive false is used for wheel, touchstart, touchmove and touchend on the root element. 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 is counted non-passive by every probe, but it blocks nothing: scroll is not cancelable and its handler contains no preventDefault call.

Can CSS scroll-behavior or scroll-driven animations replace the library?

No, on both counts. MDN scopes scroll-behavior smooth to navigation and the CSSOM scrolling APIs and says outright that scrolls performed by the user are not affected, so it does nothing for the wheel, and the specification leaves duration and easing to the user agent. Scroll-driven animations solve a different problem — binding an animation to a scroll position — and sit at roughly 85 per cent global support. Neither is a wheel lerp.

Is a transform-based smoother an equivalent choice?

Not on a page that pins with sticky and measures with getBoundingClientRect. The GSAP documentation says ScrollSmoother applies transforms to the content via matrix3d, and warns that the transform creates a new containing block — so every rect read on the page starts meaning something else. Lenis animates the real document offset, which is why one rect read anywhere inherits the smoothing for free. Locomotive Scroll reached the same conclusion in v5, dropped its virtual-scroll container and deleted its own sticky implementation.

How do you actually measure work at rest?

By instrumenting requestAnimationFrame and letting the page sit still. The trace tool wraps the function with a counter, waits three seconds after the pointer is parked, and divides. The rest gate goes further: it walks eleven scroll stops and polls the frame clock’s own live subscriber count until it reaches zero, recording both the final reading and how long it took. That distinction matters — a 2.8-second animation reads as a leak under a fixed wait and as an animation under a poll.

Sources

Measured in this repository

  • src/runtime/scroll/lenis-options.js The eight stated options and the ten deliberately inherited ones, each with the reason on its own line, and the rule that decides which is which.
  • src/runtime/scroll/scroll-controller.js The idle gate, the two chosen constants, the wake listeners, the re-anchor line and the native-mode path taken under reduced motion.
  • tests/golden/scroll-trace.json The wheel envelope on both targets, the rest cost, the listener counts and the six reduced-motion readings. Committed 2026-08-20 with a null capture timestamp.
  • tools/golden/rest.mjs The eleven-stop rest gate, the poll-to-idle method that replaced a fixed wait, and the one-vendored-callback-per-frame budget.
  • src/vendor/lenis/lenis.mjs The pinned library, committed as bytes with a hash the build verifies: the listener constant, the smoothing branch and the native return are read out of it directly.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.