On the screen, a fighting game where the main character is hitting an enemy snowman. Hand on the mouse clicks

Throttle using the example of page change on scroll

Helps not to drown in a large number of events. It will only select those that need to be responded to.

Time to read: 7 min

Briefly

Modern websites feature elements that update as the page scrolls or when its size changes.

Simply running complex and costly operations on scroll and resize events is wasteful because it can heavily load the browser and negatively impact performance.

Instead, you can handle changes "once every certain amount of time" using throttle.

Design and Task

Imagine that designers asked us to create a horizontal progress bar that shows what part of the article the user has managed to read.

This element at the top of the page should show 0% and change the value upon scrolling. Like this:

We will not pay attention to the fact that we already have a scrollbar on the right side, and we will accept the task as a given.

Markup and Styles

The markup will only have a header and an article:

        
          
          <header>  <!-- We will use the progress element as a progress bar 😃 -->  <progress value="0" max="100"></header><main>  <!-- A lot, a lot, a lot of text... --></main>
          <header>
  <!-- We will use the progress element as a progress bar 😃 -->
  <progress value="0" max="100">
</header>
<main>
  <!-- A lot, a lot, a lot of text... -->
</main>

        
        
          
        
      

In the styles, we will limit everything in width and center it:

        
          
          /* Fix the progress bar at the top of the page: */progress {  position: fixed;  top: 0;  left: 20px;  right: 20px;  width: calc(100% - 40px);  max-width: 800px;  margin: auto;}main {  padding-top: 15px;  max-width: 800px;  margin: auto;}
          /* Fix the progress bar at the top of the page: */
progress {
  position: fixed;
  top: 0;
  left: 20px;
  right: 20px;
  width: calc(100% - 40px);
  max-width: 800px;
  margin: auto;
}

main {
  padding-top: 15px;
  max-width: 800px;
  margin: auto;
}

        
        
          
        
      

Scroll Handler

First, we will write a scroll handler without optimizations.

        
          
          // We will store a reference to the element that shows the reading progress in the progress variable.const progress = document.querySelector('progress')// The recalculateProgress function will recalculate,// what part of the page the user has already managed to read.function recalculateProgress() {  // Height of the viewport:  const viewportHeight = window.innerHeight  // Height of the page:  const pageHeight = document.body.offsetHeight  // Current scroll position:  const currentPosition = window.scrollY  // We subtract the height of the viewport from the height of the page,  // so that when scrolling to the very bottom,  // the progress bar fills up completely.  const availableHeight = pageHeight - viewportHeight  // We calculate the percentage of "read" text:  const percent = (currentPosition / availableHeight) * 100  // We set the calculated value  // as the value for the progress bar:  progress.value = percent}
          // We will store a reference to the element that shows the reading progress in the progress variable.
const progress = document.querySelector('progress')

// The recalculateProgress function will recalculate,
// what part of the page the user has already managed to read.
function recalculateProgress() {
  // Height of the viewport:
  const viewportHeight = window.innerHeight
  // Height of the page:
  const pageHeight = document.body.offsetHeight
  // Current scroll position:
  const currentPosition = window.scrollY

  // We subtract the height of the viewport from the height of the page,
  // so that when scrolling to the very bottom,
  // the progress bar fills up completely.
  const availableHeight = pageHeight - viewportHeight

  // We calculate the percentage of "read" text:
  const percent = (currentPosition / availableHeight) * 100

  // We set the calculated value
  // as the value for the progress bar:
  progress.value = percent
}

        
        
          
        
      

Now we'll attach the recalculation to the scroll event as well as the resize event — to monitor changes in the height of both the page and the article.

        
          
          window.addEventListener('scroll', recalculateProgress)window.addEventListener('resize', recalculateProgress)
          window.addEventListener('scroll', recalculateProgress)
window.addEventListener('resize', recalculateProgress)

        
        
          
        
      

Writing throttle()

In this particular example, we won't notice a significant difference in performance. In recalculateProgress(), there aren't many particularly costly operations. We use a simple example to make it easier to understand the concept and not stray from throttle() itself.

However, we can see how many times the function executes in both cases using console.log():

a large number of prints in the console as a result of numerous events

We scrolled only a little (about 40–50 pixels), but the function was called 7 times.

With a skipping interval of 50 ms, the situation improved by 2.5 times (3 events), and with an interval of 150 ms it got better by 3.5 times (2 events).

If we imagine that during scrolling we "calculate a lot of complex things," then the scrolling will start to noticeably lag.

throttle() solves this problem by "skipping" some calls of the handler function. It will take the function that needs to be "skipped."

So, throttle is a higher-order function that will take the function to be "skipped" as an argument.

        
          
          // The throttle function will take 2 arguments:// - callee, the function to be called;// - timeout, the interval in ms with which calls should be skipped.function throttle(callee, timeout) {  // The timer will determine whether we should skip the current call.  let timer = null  // As a result, we return another function.  // This is necessary so that we can avoid changing other parts of the code,  // and later we will see how this helps.  return function perform(...args) {    // If the timer exists, it means the function has already been called,    // and thus the new call should be skipped.    if (timer) return    // If there is no timer, it means we can call the function:    timer = setTimeout(() => {      // We pass the arguments unchanged to the argument function:      callee(...args)      // After completion, we clear the timer:      clearTimeout(timer)      timer = null    }, timeout)  }}
          // The throttle function will take 2 arguments:
// - callee, the function to be called;
// - timeout, the interval in ms with which calls should be skipped.
function throttle(callee, timeout) {
  // The timer will determine whether we should skip the current call.
  let timer = null

  // As a result, we return another function.
  // This is necessary so that we can avoid changing other parts of the code,
  // and later we will see how this helps.
  return function perform(...args) {
    // If the timer exists, it means the function has already been called,
    // and thus the new call should be skipped.
    if (timer) return

    // If there is no timer, it means we can call the function:
    timer = setTimeout(() => {
      // We pass the arguments unchanged to the argument function:
      callee(...args)

      // After completion, we clear the timer:
      clearTimeout(timer)
      timer = null
    }, timeout)
  }
}

        
        
          
        
      

Now we can use it like this:

        
          
          // The function that we want to "skip":function doSomething(arg) {  // ...}doSomething(42)// And here is the same function, but wrapped in throttle:const throttledDoSomething = throttle(doSomething, 250)// throttledDoSomething is indeed a function,// because we return a function from throttle.// throttledDoSomething accepts the same arguments// as doSomething because perform inside throttle// passes all arguments unchanged to doSomething,// so the throttledDoSomething call will be the same// as the doSomething call:throttledDoSomething(42)
          // The function that we want to "skip":
function doSomething(arg) {
  // ...
}

doSomething(42)

// And here is the same function, but wrapped in throttle:
const throttledDoSomething = throttle(doSomething, 250)

// throttledDoSomething is indeed a function,
// because we return a function from throttle.

// throttledDoSomething accepts the same arguments
// as doSomething because perform inside throttle
// passes all arguments unchanged to doSomething,
// so the throttledDoSomething call will be the same
// as the doSomething call:
throttledDoSomething(42)

        
        
          
        
      

Applying throttle()

Now we can apply throttle() for optimizing the handler:

        
          
          function throttle(callee, timeout) {  /* ... */}// We specify that we need to wait 50 ms// before calling the function again:const optimizedHandler = throttle(recalculateProgress, 50)// We pass the new throttled function to addEventListener:window.addEventListener('scroll', optimizedHandler)window.addEventListener('resize', optimizedHandler)
          function throttle(callee, timeout) {
  /* ... */
}

// We specify that we need to wait 50 ms
// before calling the function again:
const optimizedHandler = throttle(recalculateProgress, 50)

// We pass the new throttled function to addEventListener:
window.addEventListener('scroll', optimizedHandler)
window.addEventListener('resize', optimizedHandler)

        
        
          
        
      

Note that the API of the function hasn't changed. This means that for the external world, the throttled function behaves exactly the same as a simple handler function.

This is convenient because only one small part of the program changes without affecting the system as a whole.

Result

An example of such a progress bar will look like this:

Open demo in the new window

In practice

Advice 1

Use throttle() when you need to call a function once in a certain amount of time, skipping calls in between.

For some tasks, debounce() may be more suitable — for example, for a search box that suggests query options.