🚦Throttle

A throttled function runs at most once every interval ms, however often it’s called. Unlike debounce, it keeps firing while the events keep coming.

Use it for continuous events where you want steady updates: scroll, resize, pointer move, drag, sliders.

JavaScript

It runs on the first call, then ignores calls until interval has passed.

const throttle = (fn, interval) => {
  let last = 0
  return (...args) => {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn(...args)
    }
  }
}

TypeScript

export const throttle = <Args extends unknown[]>(
  fn: (...args: Args) => void,
  interval: number,
) => {
  let last = 0
  return (...args: Args) => {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn(...args)
    }
  }
}

Compare with debounce: debounce resets a timer on every call, throttle checks the clock.

Don’t drop the last call

The catch with the version above: calls inside the interval are thrown away, including the last one. Scroll to the bottom quickly and the handler may never see the final position.

This version keeps only the latest call and runs it when the interval ends. The React hooks below use it.

export const throttleTrailing = <Args extends unknown[]>(
  fn: (...args: Args) => void,
  interval: number,
) => {
  let last = 0
  let id: ReturnType<typeof setTimeout> | undefined
  return (...args: Args) => {
    clearTimeout(id)
    const wait = Math.max(interval - (Date.now() - last), 0)
    id = setTimeout(() => {
      last = Date.now()
      fn(...args)
    }, wait)
  }
}

Usage

Scroll: progress bar and back-to-top button

const progress = document.querySelector<HTMLElement>('#progress')!
const backToTop = document.querySelector<HTMLElement>('#back-to-top')!

const onScroll = throttle(() => {
  const max = document.documentElement.scrollHeight - window.innerHeight
  progress.style.transform = `scaleX(${window.scrollY / max})`
  backToTop.hidden = window.scrollY < 600
}, 100)

// passive: we never call preventDefault, so the browser can scroll without waiting for us
window.addEventListener('scroll', onScroll, { passive: true })

Track pointer position

const sendCursor = throttle((x: number, y: number) => {
  socket.send(JSON.stringify({ type: 'cursor', x, y }))
}, 50)

document.addEventListener('pointermove', (e) => sendCursor(e.clientX, e.clientY))

Throttle to the frame rate instead

For purely visual work (moving an element with the pointer, parallax), throttle to the browser’s paint cycle with requestAnimationFrame instead of a fixed interval.

const rafThrottle = <Args extends unknown[]>(fn: (...args: Args) => void) => {
  let frame: number | undefined
  let latest: Args

  return (...args: Args) => {
    latest = args
    frame ??= requestAnimationFrame(() => {
      frame = undefined
      fn(...latest)
    })
  }
}

window.addEventListener(
  'scroll',
  rafThrottle(() => {
    header.classList.toggle('is-stuck', window.scrollY > 0)
  }),
  { passive: true },
)

Before you throttle a scroll handler, check if you need one. “Is this element on screen?” is better answered by IntersectionObserver, and many scroll effects can be pure CSS (position: sticky, scroll-driven animations).

React hooks

useThrottledValue

Throttle a value. It updates at most once per interval, and always settles on the latest value.

import { useEffect, useRef, useState } from 'react'

export function useThrottledValue<T>(value: T, interval = 200): T {
  const [throttled, setThrottled] = useState(value)
  const last = useRef(0)

  useEffect(() => {
    const remaining = interval - (Date.now() - last.current)
    const id = setTimeout(
      () => {
        last.current = Date.now()
        setThrottled(value)
      },
      Math.max(remaining, 0),
    )
    return () => clearTimeout(id)
  }, [value, interval])

  return throttled
}

useThrottledCallback

Throttle a function. Same identity between renders; always calls the latest fn.

import { useLayoutEffect, useMemo, useRef } from 'react'
import { throttleTrailing } from './throttle'

export function useThrottledCallback<Args extends unknown[]>(
  fn: (...args: Args) => void,
  interval = 200,
) {
  const fnRef = useRef(fn)
  useLayoutEffect(() => {
    fnRef.current = fn
  })

  return useMemo(
    () => throttleTrailing((...args: Args) => fnRef.current(...args), interval),
    [interval],
  )
}

useScrollY

A small hook built on top of it.

export function useScrollY(interval = 100) {
  const [y, setY] = useState(0)
  const onScroll = useThrottledCallback(() => setY(window.scrollY), interval)

  useEffect(() => {
    onScroll()
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [onScroll])

  return y
}

function BackToTop() {
  const y = useScrollY()
  if (y < 600) return null
  return <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>Back to top</button>
}

With React Query

Debounce or throttle? With a search box you want the result for the final text, so debounce. With a slider or a map you want the results to follow along while the user drags, so throttle.

Live results while dragging a slider

The slider moves smoothly on every change. The query fires at most every 300ms, so the results update while dragging without sending a request for every pixel.

import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useThrottledValue } from './useThrottledValue'

function PriceFilter() {
  const [maxPrice, setMaxPrice] = useState(500)
  const throttledMax = useThrottledValue(maxPrice, 300)

  const { data: products = [], isFetching } = useQuery({
    queryKey: ['products', { maxPrice: throttledMax }],
    queryFn: ({ signal }) =>
      fetch(`/api/products?maxPrice=${throttledMax}`, { signal }).then((r) => r.json()),
    placeholderData: keepPreviousData,
  })

  return (
    <>
      <label>
        Max price: £{maxPrice}
        <input
          type="range"
          min={0}
          max={1000}
          value={maxPrice}
          onChange={(e) => setMaxPrice(Number(e.target.value))}
        />
      </label>
      <ProductGrid products={products} dimmed={isFetching} />
    </>
  )
}

The same pattern works for map bounds as the user pans (queryKey: ['places', throttledBounds]).

Infinite scroll with useInfiniteQuery

Check the scroll position at most every 200ms and load the next page near the bottom. The hasNextPage / isFetchingNextPage guard stops duplicate requests.

import { useInfiniteQuery } from '@tanstack/react-query'

type Page = { items: Post[]; nextCursor: string | null }

function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: async ({ pageParam, signal }): Promise<Page> => {
      const res = await fetch(`/api/feed?cursor=${pageParam ?? ''}`, { signal })
      return res.json()
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

  const onScroll = useThrottledCallback(() => {
    const nearBottom =
      window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 800
    if (nearBottom && hasNextPage && !isFetchingNextPage) fetchNextPage()
  }, 200)

  useEffect(() => {
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [onScroll])

  return (
    <ul>
      {data?.pages.flatMap((page) => page.items).map((post) => <PostRow key={post.id} post={post} />)}
      {isFetchingNextPage && <li>Loading…</li>}
    </ul>
  )
}

An IntersectionObserver on a sentinel element at the end of the list does the same job without a scroll listener. Use the throttled version when you already have a scroll handler, or need the position for other things.

Throttle refetches from rapid events

Some events fire in bursts: a websocket that pushes many “something changed” messages, or a real-time cursor. Throttle the invalidation so the list refetches at most once a second instead of once per message.

function useLiveOrders() {
  const queryClient = useQueryClient()
  const invalidate = useThrottledCallback(
    () => queryClient.invalidateQueries({ queryKey: ['orders'] }),
    1000,
  )

  useEffect(() => {
    const ws = new WebSocket('wss://example.com/orders')
    ws.addEventListener('message', invalidate)
    return () => ws.close()
  }, [invalidate])

  return useQuery({ queryKey: ['orders'], queryFn: fetchOrders })
}