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.

JavaScriptintermediate
15 min read

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

javascriptjavascript
// 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

javascriptjavascript
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

htmlhtml
<div class="progress-container">
  <div id="progress-bar" class="progress-bar"></div>
</div>
csscss
.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;
}
javascriptjavascript
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:

javascriptjavascript
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

PatternIntervalWhy
Progress bar50msNeeds smooth visual updates
Lazy loading200msOnly needs to check viewport periodically
Infinite scroll200msTrigger point check is cheap
Sticky header100msVisual state change should feel responsive
Analytics1000msBackground tracking, precision not needed

Pattern 3: Infinite Scroll

javascriptjavascript
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

javascriptjavascript
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 });
}
csscss
.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

javascriptjavascript
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

MethodFrame AlignmentPrecisionBest For
setTimeout throttleNoFixed intervalData-driven checks (lazy load, infinite scroll)
requestAnimationFrameYes~16ms at 60fpsVisual transforms, parallax, opacity changes
IntersectionObserverBrowser-managedThreshold-basedElement visibility detection
Rune AI

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 removeEventListener and .cancel() to prevent memory leaks in single-page applications
RunePowered by Rune AI

Frequently Asked Questions

Should I use IntersectionObserver instead of scroll events?

Yes, for visibility detection (lazy loading, entering viewport). `IntersectionObserver` is more performant because the browser handles the observation natively. Use scroll events when you need the exact scroll position (progress bars, parallax).

What does `{ passive: true }` actually do?

It tells the browser that the event listener will never call `event.preventDefault()`. This allows the browser to start scrolling immediately on a separate thread instead of waiting for your JavaScript handler to finish. See [browser Web APIs in JavaScript complete guide](/tutorials/programming-languages/javascript/browser-web-apis-in-javascript-complete-guide) for more event listener options.

How do I detect scroll direction?

Store the previous `scrollY` value and compare: if `currentScrollY > lastScrollY`, the user is scrolling down. Update `lastScrollY` at the end of each handler call.

Can scroll throttling cause missed events?

The trailing edge ensures you always get the final scroll position. Use the timer-based throttle (not timestamp-based) to guarantee the last call fires after the scroll stops.

How do I clean up scroll listeners in SPAs?

Store the throttled function reference, call `removeEventListener` with it, and call `.cancel()` to clear any pending timers. Do this in your component's unmount/cleanup lifecycle method.

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.