Throttling infinite scrolling
Here are 5 essential frontend interview questions based directly on this infinite scroll implementation, complete with what the interviewer is looking for and high-scoring answers.
Q1: Why did you use a useRef instead of a standard useState to track the loading state inside the scroll event listener?¶
- What they are testing: Your understanding of asynchronous state, closures, and how React hooks manage memory.
- Answer: React state updates are asynchronous and scheduled. When useEffect runs with an empty dependency array [], the event listener captures a snapshot of the initial state (a "stale closure"). If a user scrolls rapidly, the scroll event fires dozens of times before React finishes updating the loading state to true. This causes multiple duplicate API calls.A useRef object persists across renders and its .current property updates synchronously and instantly. By checking isFetching.current inside the listener, we create an immediate safeguard that blocks duplicate triggers on the very next pixel scroll.
Q2: What is the technical difference between Throttling and Debouncing? Why is Throttling preferred for infinite scrolling?¶
- What they are testing: Performance optimization concepts and matching the right technique to the user experience.
- Answer:
- Throttling enforces a maximum number of times a function can be called over time (e.g., "execute this function at most once every 400ms").
- Debouncing delays the function execution until a specific amount of time has passed since the last call (e.g., "wait until the user stops typing for 400ms"). For infinite scrolling, throttling is preferred because we need to continuously calculate the user's scroll position as they move down the page. If we used debouncing, the calculation would only fire after the user completely stopped moving their mouse or finger, making the infinite scroll feel laggy, staggered, and unresponsive.
Q3: Your infinite scroll math breaks or triggers instantly on high-DPI (Retina) screens or when the browser is zoomed in. How do you fix this?¶
- What they are testing: Real-world cross-browser compatibility issues and layout math precision.
- Answer: Browsers handle sub-pixel precision differently on high-DPI displays or when zoomed. The calculation window.innerHeight + window.scrollY might equal 1200.5px, while document.body.offsetHeight evaluates to an integer like 1201px. Because of that missing 0.5px, an exact match check (==) fails.To fix this, we apply two defensive programming techniques:
- Use a greater-than-or-equal check (>=) instead of exact equality.
- Implement a buffer/threshold (like - 100 or - 50). This triggers the loading mechanism slightly before the user hits the absolute bottom pixel, masking network latency and avoiding sub-pixel truncation errors.
Q4: If the initial API call returns only 2 items on page load, your infinite scroll breaks and stops loading more. Why does this happen and how do you fix it?¶
- What they are testing: Edge-case debugging and layout-driven architecture.
- Answer: If the initial data batch is too small to exceed the height of the viewport, no vertical scrollbar is created. Because the user cannot scroll, the scroll event listener can never fire to fetch the next batch, trapping the application in a broken state.To fix this, we can implement a layout fallback check. After the initial items render, we can use a useEffect to check if document.documentElement.scrollHeight <= window.innerHeight. If it is true, we programmatically trigger loadMoreItems() again until a scrollbar naturally appears. Alternatively, we can swap the scroll listener for the modern Intersection Observer API watching a boundary element at the bottom of the list.
Q5: Why is the cleanup function (return () => window.removeEventListener...) inside useEffect critical here? What happens if you forget it?¶
- What they are testing: Memory management and preventing memory leaks in single-page applications (SPAs).
- Answer: If you omit the cleanup function, the event listener remains attached to the global window object even if the component unmounts (e.g., the user navigates to a different page).If the user navigates back and forth multiple times, a new event listener is appended to window on every mount. This causes a severe memory leak, as old unmounted component closures are held in memory and cannot be garbage collected. Eventually, scrolling will trigger dozens of ghost functions simultaneously, crashing browser performance.
If you want, I can: Show you how to answer questions about virtualizing long lists (React Window)Provide an interview answer explaining Intersection Observer vs Scroll Events
import React, { useState, useEffect, useRef } from "react";
export default function GuaranteedInfiniteScroll() {
const [items, setItems] = useState(["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]);
const [loading, setLoading] = useState(false);
// Use a Ref to track loading instantly. This prevents duplicate API calls.
const isFetching = useRef(false);
// Track throttle timing across renders
const lastCall = useRef(0);
useEffect(() => {
const handleScroll = () => {
const now = Date.now();
// 1. THROTTLE: Skip if less than 400ms have passed since the last check
if (now - lastCall.current < 400) return;
lastCall.current = now;
// 2. MATH: Check if user scrolled near the bottom (with a safe 100px buffer)
// Total height of the entire web page from top to bottom, including the invisible parts below the fold.
const totalHeight = document.documentElement.scrollHeight;
// The current distance (in pixels) that the user has scrolled down from the very top of the page.
// (Uses window.scrollY first, falling back to documentElement.scrollTop for cross-browser support).
// scrollTop is a measurement (in pixels) of how far an element has been scrolled vertically from its top edge.
const scrolledFromTop = window.scrollY || document.documentElement.scrollTop;
// The height of the user's visible screen area (the browser window viewport itself).
const visibleHeight = window.innerHeight;
const reachedBottom = visibleHeight + scrolledFromTop >= totalHeight - 100;
// 3. GUARD: Only fetch if we are at the bottom AND not already loading
if (reachedBottom && !isFetching.current) {
isFetching.current = true;
setLoading(true);
// Simulate network API delay
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 5 }, (_, i) => `Item ${prev.length + i + 1}`)
// Why do developers use _ instead of a word?It is purely a visual signal for humans.When you write _, you are telling anyone reading your code: "Hey, I am forced // to //create a variable here to reach the second parameter, but I don't plan on using this first variable at all. Ignore it."
]);
setLoading(false);
isFetching.current = false; // Unlock for the next scroll trigger
}, 800);
}
};
// Attach listener
window.addEventListener("scroll", handleScroll);
// Clean up listener to prevent memory leaks
return () => window.removeEventListener("scroll", handleScroll);
}, []); // Empty array means this runs exactly once on mount
return (
<div style={{ padding: "20px", maxWidth: "400px", margin: "auto" }}>
<h2>Guaranteed Infinite Scroll</h2>
<div style={{ minHeight: "100vh" }}> {/* Ensures page has initial height to scroll */}
{items.map((item, index) => (
<div
key={index}
style={{
padding: "40px 20px",
margin: "15px 0",
background: "#eaeaea",
borderRadius: "8px",
textAlign: "center"
}}
>
{item}
</div>
))}
</div>
{loading && <p style={{ textAlign: "center", fontWeight: "bold" }}>Loading 5 more...</p>}
</div>
);
}