I built an infinite scroll feed for a social media feature back in my early days as a developer. The list loaded smoothly on the first scroll. Then I noticed the bug: sometimes the same page loaded twice. The data repeated. The layout jumped. Users started tweeting screenshots of the glitch.
My first instinct was to reach for debounce. I had just learned about it and thought it could fix anything. I slapped a 200ms debounce on the scroll handler and watched the network tab. The duplicates were fewer, but they still appeared when the user scrolled fast. Worse, the feed now felt laggy — it waited for a pause before fetching the next page, which made infinite scroll feel like a paginated page with extra steps. That is when I discovered the load gate pattern, and it completely changed how I think about coordinating async operations.
Why Debounce Fails for Scroll
Debounce works by waiting for a pause in events. When you type a search query, each keystroke fires an event, and debounce waits until you stop typing before firing the API call. That makes sense because typing is discrete — there is a clear beginning, middle, and end to each word.
Scroll is not discrete. A single flick of a finger on a trackpad produces dozens of scroll events in under a second, with no natural pause until the momentum dies. If you apply a 200ms debounce to a scroll handler, the function only fires 200ms after scrolling stops completely. The user reaches the bottom of the page, stops, waits 200ms, and then the content loads. That delay breaks the illusion of infinite scroll — it feels like clicking a “Next” button.
Lowering the debounce delay does not help. At 50ms, the threshold is so short that a single scroll gesture passes through multiple times, each triggering a separate fetch. Those fetches overlap, return the same data, and append duplicate items to the DOM. The user sees flickering rows and a layout that jumps. I once debugged a case where a news feed loaded 18 items instead of 6 because the debounce was too short and three fetches completed in parallel, each appending the same 6 items.
The fundamental issue is that debounce coordinates with time, not with state. It does not know whether a fetch is in progress. It only knows how long it has been since the last event. What you need is a mechanism that looks at the current state of the system — is something already loading? — not at the clock.
The Load Gate Pattern
A load gate is a boolean flag that tracks whether a fetch is currently running. When a new scroll event arrives, the gate checks the flag. If a fetch is in progress, the event is silently ignored. If no fetch is running, the gate opens and a new fetch starts. When the fetch completes — whether it succeeds or fails — the gate closes.
let isLoading = false
async function loadMore() {
if (isLoading) return
isLoading = true
try {
const data = await fetchNextPage()
appendItems(data)
} finally {
isLoading = false
}
}The pattern is not limited to infinite scroll. I have used it in three other scenarios recently.
The first is button submission. A user clicks “Place Order” twice in rapid succession because the page is slow. Without a gate, two identical orders are created. With a gate set on the first click, the second click finds isSubmitting = true and does nothing. This is simpler than disabling the button and re-enabling it after the response, because the button stays interactive — it just ignores the second click.
The second is tab switching. I worked on a dashboard where clicking a tab fetches new data from the API. Rapidly clicking between tabs created a race condition: the response for tab A arrived after the response for tab B, so the wrong data was displayed. A load gate per tab prevented overlapping fetches entirely.
The third is file upload. A drag-and-drop upload widget fired multiple upload requests if the user dropped files rapidly. Adding a gate that blocked new uploads while one was processing prevented the server from receiving partial or duplicate files.
The finally block is the most important part of this pattern. If the fetch throws an error and you forget to reset the flag, the gate stays locked forever. The user sees a spinner that never disappears. I learned this the hard way when a network outage broke our infinite scroll for an entire afternoon before we noticed the pattern: the gate was locked and nobody thought to check that the finally block was missing.
Combining With IntersectionObserver
The modern way to trigger infinite scroll is the IntersectionObserver API. You place a sentinel element at the bottom of your list. When it becomes visible, the observer fires and calls loadMore. The observer can fire multiple times per scroll — but the load gate blocks redundant calls.
const sentinel = document.getElementById("scroll-sentinel")
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadMore()
})
observer.observe(sentinel)Try the simulation below. Click the button rapidly two or three times in quick succession to simulate fast scrolling. The left side (no gate) shows duplicate fetches stacking on top of each other — notice how the call count goes up even though a fetch is still running. The right side (with gate) blocks all requests that arrive while a fetch is running, so the call count only increases when no operation is in progress.
This combination is the production pattern used by Twitter, Instagram, and Medium. The observer detects proximity to the bottom. The gate prevents duplicate fetches. Together they create a seamless infinite scroll that never loads the same page twice. There is no delay, no timeout, no debounce — the system is purely event-driven and state-based. The observer fires, the gate checks the flag, and either proceeds or ignores. This makes it faster and more predictable than any timer-based approach.
A common variation is to add a small guard against the initial load. When the page first renders, the sentinel might already be visible, causing an immediate fetch. Some implementations add a check for !isInitialLoad or wait for the user to interact first. I prefer to let the first fetch happen — it pre-fills the feed faster — but I add a minimum duration spinner so the user sees a smooth transition instead of a flash of empty space.
Another pattern I have used in production is an error gate. If a fetch fails, the gate stays locked for a retry interval (say 5 seconds) before allowing another attempt. This prevents the user from hammering the server with retries when it is already struggling. The retry gate is a separate boolean that resets on a timer, while the main load gate continues to block duplicate fetches during the retry wait.
The load gate concept extends beyond scrolling. Any time you have an event that triggers an async operation, and that event can fire faster than the operation can complete, a boolean flag is the simplest and most reliable guard. No timers, no third-party libraries, no complex state management. Just a boolean and a try/finally block.
