Throttled scroll handler misses the last position: adding trailing calls in JavaScript
Our reading progress bar stopped at 92% and saved scroll positions were always slightly behind. A hand-written leading-edge throttle dropped the final scroll event of every gesture. We added a trailing call, tested it with fake timers, and compared requestAnimationFrame and scrollend.
Two bug reports arrived the same week on our long-form article pages. Readers said the progress bar at the top never reached the end; it stopped somewhere between 85% and 95% when they finished an article. Separately, the "continue where you left off" feature returned people to a spot a screen or so above where they had stopped.
Both features listened to scroll through the same helper. Nothing showed in the console and nothing failed in tests.
Reproducing it
On a laptop with a trackpad, a single fast fling to the bottom of an article left the bar at roughly 90%. Scrolling slowly with the arrow keys reached 100%. Logging inside the handler showed the last logged scrollY was smaller than the final window.scrollY read afterwards from the console:
handler scrollY=10412
handler scrollY=11630
handler scrollY=12188
> window.scrollY
12544
What we ruled out
- Wrong maths in the progress calculation. Calling the handler manually at the bottom produced 100%.
- Layout shifts from lazy-loaded images. Reserving image dimensions changed nothing.
- Passive listener or browser quirks. The same behaviour appeared in Chrome, Firefox and Safari.
Root cause
The helper was a small throttle written years ago:
export function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
It runs on the leading edge only: the first event in each window passes, and every other event inside the window is discarded. When scrolling stops, the last events of the gesture almost always fall inside a window that has already fired. They are thrown away, and nothing runs afterwards. The handler's last view of the page is whatever position it saw up to wait milliseconds before scrolling ended.
What is happening under the hood
Throttling limits how often a function runs: at most once per wait milliseconds. There are two edges where a call can happen:
- Leading edge: run immediately on the first event of a window. Responsive, but on its own it forgets everything that happens afterwards in that window.
- Trailing edge: after the window ends, run once more with the most recent arguments if any events arrived during it.
A throttle that only fires on the leading edge is fine for actions where the latest state does not matter, like "fire analytics once when the user starts scrolling". For anything that tracks state (position, size, input value) the last event is the most important one, because it is the state the page ends in. Dropping it leaves the UI stale until the next event, which may never come.
Debouncing is the other common tool and is not a substitute: it waits until events stop for wait milliseconds and then fires once. For a progress bar that would mean no updates at all while the user scrolls.
| Technique | Fires during a continuous gesture | Fires with the final state |
|---|---|---|
| Leading-only throttle (our bug) | yes, every wait ms |
no |
| Throttle with trailing call | yes | yes |
| Debounce | no | yes |
requestAnimationFrame batching |
once per frame | yes |
Environment
| Component | Version / value |
|---|---|
| Browsers | Chrome 135, Firefox 137, Safari 18.4 |
| Bundler | Vite 6.2 |
| Test runner | Vitest 3.1 with jsdom |
| Throttle wait | 200 ms |
| Affected features | reading progress bar, saved scroll position |
Minimal reproduction
import { throttle } from "./throttle.js";
const seen = [];
const onScroll = throttle((y) => seen.push(y), 100);
// simulate 10 events, 30 ms apart, ending at y=900
let y = 0;
const id = setInterval(() => {
y += 100;
onScroll(y);
if (y === 1000) clearInterval(id);
}, 30);
setTimeout(() => console.log(seen), 1000);
// [ 100, 500, 900 ] -- the final 1000 is never delivered
Who is affected
Any code using a home-made leading-only throttle, or a library throttle called with { trailing: false }, for scroll, resize, pointer move, or input events where the last value matters. Fast gestures (trackpad flings, mobile momentum scrolling) make it worse, because the gesture ends within a single window more often.
The fix
We rewrote the helper to fire on both edges, delivering the most recent arguments after the window closes:
export function throttle(fn, wait) {
let lastRun = 0;
let timer = null;
let pendingArgs = null;
let pendingThis = null;
function invoke() {
lastRun = Date.now();
timer = null;
const args = pendingArgs;
const ctx = pendingThis;
pendingArgs = pendingThis = null;
fn.apply(ctx, args);
}
function throttled(...args) {
pendingArgs = args;
pendingThis = this;
const remaining = wait - (Date.now() - lastRun);
if (remaining <= 0) {
if (timer) {
clearTimeout(timer);
}
invoke();
} else if (!timer) {
timer = setTimeout(invoke, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timer);
timer = null;
pendingArgs = pendingThis = null;
};
return throttled;
}
Key points:
- Every call stores the latest arguments, so the trailing call uses the newest position, not the first one seen in the window.
- At most one timer is pending, and it is scheduled for the exact remaining time, so the trailing call lands at the window boundary.
cancel()lets components remove the pending call on unmount, which avoids writing state for a page the user already left.
The scroll handler reads position at call time rather than relying on arguments:
const updateProgress = throttle(() => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const ratio = max > 0 ? window.scrollY / max : 1;
bar.style.transform = `scaleX(${Math.min(1, ratio)})`;
}, 100);
window.addEventListener("scroll", updateProgress, { passive: true });
We also cut wait from 200 ms to 100 ms for the progress bar; a visual indicator updating five times a second looked jumpy.
Alternatives and trade-offs
- lodash
throttle. Its defaults are{ leading: true, trailing: true }, which is the behaviour we wanted, pluscancelandflush. We did not want a dependency for one helper, but it is a solid choice if lodash is already in the bundle. requestAnimationFramefor visual updates. For the progress bar, syncing to frames is arguably more correct than a fixed interval: at most one update per frame, and the last scroll always triggers one more frame.
let scheduled = false;
window.addEventListener("scroll", () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
renderProgress(window.scrollY);
});
}, { passive: true });
- The
scrollendevent fires once when scrolling finishes, including after momentum and smooth scrolling. It is ideal for "save position" logic. Support was not universal across the browsers we target, so we use it as a progressive enhancement and keep the throttled save as a fallback:
if ("onscrollend" in window) {
window.addEventListener("scrollend", () => savePosition(window.scrollY));
} else {
window.addEventListener("scroll", throttle(() => savePosition(window.scrollY), 500), { passive: true });
}
- IntersectionObserver is a better fit when you only need to know whether specific elements are visible (for example "reached the end of the article"), because it runs off the main scroll path.
How to test the fix
Vitest fake timers make throttle behaviour deterministic:
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { throttle } from "./throttle.js";
describe("throttle", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("delivers the final call after the window", () => {
const fn = vi.fn();
const t = throttle(fn, 100);
t(1); // leading call
vi.advanceTimersByTime(30);
t(2);
vi.advanceTimersByTime(30);
t(3); // last event in the window
expect(fn.mock.calls).toEqual([[1]]);
vi.advanceTimersByTime(40);
expect(fn.mock.calls).toEqual([[1], [3]]);
});
it("does not fire a trailing call after cancel", () => {
const fn = vi.fn();
const t = throttle(fn, 100);
t(1);
t(2);
t.cancel();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Vitest's fake timers also mock Date.now(), so the helper sees consistent time.
npx vitest run src/utils/throttle.test.js
Confirming in production
We added a small check to the article page in a staged rollout: when scrollend fires (on browsers that support it), compare the last progress value the throttled handler rendered with the value computed from the final window.scrollY, and report the difference to our analytics endpoint as a sampled metric.
window.addEventListener("scrollend", () => {
const expected = computeProgress(window.scrollY);
sendMetric("progress_drift", Math.abs(expected - lastRenderedProgress));
});
Before the fix, median drift was about 6% and p95 about 14%. After the fix both were 0 within a rounding tolerance, and the saved-position feature returned readers to within a few pixels of where they stopped. Support tickets about the progress bar stopped within the week. We removed the drift metric after a month.
Regression guard
- The fake-timer tests above run in CI for the shared helper.
- The helper's JSDoc states that it fires on both edges, and a code search confirmed no remaining copies of the old leading-only version.
- A Playwright test scrolls an article to the bottom with
page.mouse.wheeland asserts the progress bar's transform isscaleX(1)after a short wait.
Security implications
None. The change only affects when a client-side handler runs. One side effect we checked: the trailing call could fire after a single-page navigation, writing a saved position for the previous article to the new one. Calling cancel() in the route-change cleanup prevents that, and avoids sending stale data to the save endpoint.
Prevention checklist
- Use throttles that fire on both edges for anything that tracks state.
- Always deliver the most recent arguments in the trailing call.
- Expose and call
cancel()when a component or route is torn down. - Prefer
requestAnimationFramefor visual updates andscrollendfor end-of-scroll work where supported. - Test throttle and debounce helpers with fake timers.
References
- MDN, Element scroll event: https://developer.mozilla.org/en-US/docs/Web/API/Element/scroll_event
- MDN, scrollend event: https://developer.mozilla.org/en-US/docs/Web/API/Document/scrollend_event
- MDN, requestAnimationFrame: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
- lodash throttle documentation: https://lodash.com/docs/#throttle
- Vitest fake timers: https://vitest.dev/guide/mocking.html
- MDN, Intersection Observer API: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
Add a label
Embed this post
<iframe src="https://www.hibug.me/javascript-throttle-drops-final-event" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
Invoice PDFs showed garbage for accented customer names: jsPDF standard fonts and embedding TTF
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.