Scroll Event Throttling in JavaScript: Full Guide
A complete guide to scroll event throttling in JavaScript. Covers the performance impact of unthrottled scroll handlers, implementing throttled scroll tracking, building a scroll progress bar, lazy loading images, infinite scroll pagination, sticky header on scroll, back-to-top button, and scroll-linked animations with requestAnimationFrame.
The scroll event fires dozens of times per second during scrolling. Attaching unthrottled handlers that read layout properties or modify the DOM causes layout thrashing, dropped frames, and a janky user experience. This guide builds five production scroll patterns, each properly throttled for smooth 60fps performance.
Why Scroll Events Need Throttling
// Measuring raw scroll event frequency
let count = 0;
window.addEventListener("scroll", () => count++);
setTimeout(() => {
console.log(`Scroll events in 1 second: ${count}`);
// Typical output: 30-120 events per second
}, 1000);Each scroll event that reads scrollY, offsetHeight, or getBoundingClientRect() forces the browser to calculate layout. If you also modify DOM properties, the browser must recalculate layout again, creating a read-write-read-write cycle called layout thrashing.
Throttle Utility
function throttle(fn, interval) {
let isThrottled = false;
let savedArgs = null;
let savedThis = null;
function wrapper(...args) {
if (isThrottled) {
savedArgs = args;
savedThis = this;
return;
}
fn.apply(this, args);
isThrottled = true;
setTimeout(() => {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = null;
savedThis = null;
}
}, interval);
}
wrapper.cancel = () => {
clearTimeout(isThrottled);
isThrottled = false;
savedArgs = null;
savedThis = null;
};
return wrapper;
}See throttling in JavaScript a complete tutorial for leading/trailing options and the timestamp-based approach.
Pattern 1: Scroll Progress Bar
<div class="progress-container">
<div id="progress-bar" class="progress-bar"></div>
</div>.progress-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: transparent;
z-index: 1000;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.1s ease-out;
}function updateProgress() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
document.getElementById("progress-bar").style.width = `${progress}%`;
}
const throttledProgress = throttle(updateProgress, 50);
window.addEventListener("scroll", throttledProgress, { passive: true });
// Initial update
updateProgress();Pattern 2: Lazy Loading Images
While IntersectionObserver is preferred, throttled scroll-based lazy loading works in older environments:
function lazyLoadImages() {
const images = document.querySelectorAll("img[data-src]");
const viewportHeight = window.innerHeight;
images.forEach((img) => {
const rect = img.getBoundingClientRect();
// Load if within 200px of viewport
if (rect.top < viewportHeight + 200 && rect.bottom > -200) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
img.classList.add("loaded");
}
});
}
const throttledLazy = throttle(lazyLoadImages, 200);
window.addEventListener("scroll", throttledLazy, { passive: true });
// Load visible images on page load
lazyLoadImages();Scroll Throttle Intervals by Pattern
| Pattern | Interval | Why |
|---|---|---|
| Progress bar | 50ms | Needs smooth visual updates |
| Lazy loading | 200ms | Only needs to check viewport periodically |
| Infinite scroll | 200ms | Trigger point check is cheap |
| Sticky header | 100ms | Visual state change should feel responsive |
| Analytics | 1000ms | Background tracking, precision not needed |
Pattern 3: Infinite Scroll
class InfiniteScroller {
constructor(options) {
this.container = document.getElementById(options.containerId);
this.loadMore = options.loadMore;
this.threshold = options.threshold || 300;
this.isLoading = false;
this.hasMore = true;
this.page = 1;
this.throttledCheck = throttle(this.checkPosition.bind(this), 200);
window.addEventListener("scroll", this.throttledCheck, { passive: true });
}
checkPosition() {
if (this.isLoading || !this.hasMore) return;
const scrollBottom = window.scrollY + window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (docHeight - scrollBottom < this.threshold) {
this.loadNextPage();
}
}
async loadNextPage() {
this.isLoading = true;
this.showSpinner();
try {
const items = await this.loadMore(this.page);
if (items.length === 0) {
this.hasMore = false;
this.showEndMessage();
return;
}
items.forEach((item) => {
this.container.appendChild(this.createItemElement(item));
});
this.page++;
} catch (error) {
this.showError();
} finally {
this.isLoading = false;
this.hideSpinner();
}
}
createItemElement(item) {
const el = document.createElement("article");
el.className = "feed-item";
el.innerHTML = `<h3>${item.title}</h3><p>${item.excerpt}</p>`;
return el;
}
showSpinner() {
let spinner = document.getElementById("scroll-spinner");
if (!spinner) {
spinner = document.createElement("div");
spinner.id = "scroll-spinner";
spinner.className = "spinner";
spinner.textContent = "Loading more...";
this.container.after(spinner);
}
spinner.hidden = false;
}
hideSpinner() {
const spinner = document.getElementById("scroll-spinner");
if (spinner) spinner.hidden = true;
}
showEndMessage() {
const msg = document.createElement("p");
msg.className = "end-message";
msg.textContent = "You have reached the end.";
this.container.after(msg);
}
showError() {
const err = document.createElement("p");
err.className = "error-message";
err.textContent = "Failed to load more items. Scroll to try again.";
this.container.after(err);
}
destroy() {
window.removeEventListener("scroll", this.throttledCheck);
this.throttledCheck.cancel();
}
}
// Usage
const scroller = new InfiniteScroller({
containerId: "feed",
threshold: 400,
loadMore: async (page) => {
const res = await fetch(`/api/articles?page=${page}&limit=10`);
const data = await res.json();
return data.articles;
},
});Pattern 4: Sticky Header
function setupStickyHeader() {
const header = document.querySelector(".site-header");
const headerHeight = header.offsetHeight;
let lastScrollY = 0;
function handleScroll() {
const currentScrollY = window.scrollY;
if (currentScrollY > headerHeight) {
header.classList.add("scrolled");
// Hide on scroll down, show on scroll up
if (currentScrollY > lastScrollY && currentScrollY > headerHeight * 2) {
header.classList.add("hidden");
} else {
header.classList.remove("hidden");
}
} else {
header.classList.remove("scrolled", "hidden");
}
lastScrollY = currentScrollY;
}
const throttledHeader = throttle(handleScroll, 100);
window.addEventListener("scroll", throttledHeader, { passive: true });
}.site-header {
position: sticky;
top: 0;
transition: transform 0.3s ease;
z-index: 100;
}
.site-header.scrolled {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.site-header.hidden {
transform: translateY(-100%);
}Pattern 5: Scroll-Linked Animation With rAF
function setupParallax() {
const layers = document.querySelectorAll("[data-parallax]");
function updateParallax() {
const scrollY = window.scrollY;
layers.forEach((layer) => {
const speed = parseFloat(layer.dataset.parallax) || 0.5;
const offset = scrollY * speed;
layer.style.transform = `translate3d(0, ${offset}px, 0)`;
});
}
let rafId = null;
function onScroll() {
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateParallax();
rafId = null;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
updateParallax();
}Throttle Methods Comparison
| Method | Frame Alignment | Precision | Best For |
|---|---|---|---|
setTimeout throttle | No | Fixed interval | Data-driven checks (lazy load, infinite scroll) |
requestAnimationFrame | Yes | ~16ms at 60fps | Visual transforms, parallax, opacity changes |
IntersectionObserver | Browser-managed | Threshold-based | Element visibility detection |
Rune AI
Key Insights
- Unthrottled scroll handlers cause layout thrashing: Reading
scrollY+ modifying DOM in a tight loop forces repeated layout recalculations - passive: true is mandatory for scroll listeners: Without it, the browser blocks scrolling until your handler completes
- Use requestAnimationFrame for visual updates: Frame-aligned execution prevents tearing and produces smooth animations on all display refresh rates
- IntersectionObserver replaces scroll-based visibility checks: Use it for lazy loading and viewport entry detection instead of manual scroll calculations
- Always store and clean up throttled references: Call
removeEventListenerand.cancel()to prevent memory leaks in single-page applications
Frequently Asked Questions
Should I use IntersectionObserver instead of scroll events?
What does `{ passive: true }` actually do?
How do I detect scroll direction?
Can scroll throttling cause missed events?
How do I clean up scroll listeners in SPAs?
Conclusion
Every scroll-driven feature needs either throttling or requestAnimationFrame to maintain 60fps. Use 50ms throttle for visual feedback (progress bars), 200ms for viewport checks (lazy loading, infinite scroll), 100ms for interaction state (sticky headers), and requestAnimationFrame for transforms (parallax, opacity). Always add { passive: true } to scroll listeners. For the throttle function implementation, see throttling in JavaScript a complete tutorial. For the debounce alternative, see debouncing in JavaScript a complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.