Capture product visible on viewport when user stops scrolling

This system design question was asked to one of my LinkedIn connection in NoBroker’s interview. When he approached me regarding the solution of this. It immediately caught my attention and I solved this problem that day itself.

As it is an interesting problem I thought of writing an article around it, so here it is.

The question was quoted as “If user scroll and see any property and stays there for more than 5 sec then call API and store that property”.

Apart from the online real-estate platform, this can be also applied on others platforms as well, such as social media like Facebook where if users read a post for few seconds, store and use it to provide recommendations of new posts. The same can be used on an E-commerce platforms or other platforms where products are listed.

Let us see how should we approach such problems and then solve them with an example. I have created a dummy HTML template that contains different blocks, which we can use for testing.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .wrapper{
            display: flex;
            align-items: center;
            justify-content: center;
            flex-wrap: wrap;
        }

        .blocks{
            flex: 1 300px;
            height: 300px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            margin: 5px;
            background: red;
            font-size: 40px;
            color: #fff;
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <div class="blocks">1</div>
        <div class="blocks">2</div>
        <div class="blocks">3</div>
        <div class="blocks">4</div>
        <div class="blocks">5</div>
        <div class="blocks">6</div>
        <div class="blocks">7</div>
        <div class="blocks">8</div>
        <div class="blocks">9</div>
        <div class="blocks">10</div>
        <div class="blocks">11</div>
        <div class="blocks">12</div>
        <div class="blocks">13</div>
        <div class="blocks">14</div>
        <div class="blocks">15</div>
        <div class="blocks">16</div>
        <div class="blocks">17</div>
        <div class="blocks">18</div>
        <div class="blocks">19</div>
        <div class="blocks">20</div>
        <div class="blocks">21</div>
        <div class="blocks">22</div>
        <div class="blocks">23</div>
        <div class="blocks">24</div>
        <div class="blocks">25</div>
        <div class="blocks">26</div>
        <div class="blocks">27</div>
    </div>
</body>
</html>

Now when this web page will be scrolled, we will log the blocks which are within the viewport whenever the user stops for more than 1 second.

A vital thing to keep in mind is to read the problem statement multiple times and then break the problem into sub-problems so that we can tackle each of them independently.

By reading the problem statement, I figured two sub-problems and decided to break them into two parts.

  1. A way to check if the element is within the viewport.
  2. A way to make the API call only after the user stops scrolling and waits for some time (5 secs in this case), if the user scrolls before that then we should revoke the call.

Check if an element is within the viewport.

“Within the viewport” means then elements that are within the visible part of the screens not within the visible area.

For this we will create a function that will return true or false, depending upon whether the element is within the viewport or not.

To determine this we will use the Element.getBoundingClientRect() method which returns the elements position within the viewport. It returns an object with an element’s height and width, as well as it’s distance from the top, bottom, left, and right of the viewport.

// Get the H1
const h1 = document.querySelector('h1');

// Get it's position in the viewport
const bounding = h1.getBoundingClientRect();

// Log
console.log(bounding);
// {
// 	height: 118,
// 	width: 591.359375,
// 	top: 137,
// 	bottom: 255,
// 	left: 40.3125,
// 	right: 631.671875
// }

The next thing after getting the elements placement details is to determine if it is within the viewport.

If an element is in the viewport then its position from top and left will always be greater than or equal to 0. It’s distance from the right will be less than or equal to the total width of the viewport, and it’s distance from the bottom will be less than or equal to the height of the viewport.

There are a couple of ways to get the width and height of the viewport.

For width, Some browsers support window.innerWidth while some support document.documentElement.clientWidth and some support both. We try using one of them and other as fallback to get the width using OR operator.

(window.innerWidth || document.documentElement.clientWidth)

Similarly to get the height, some browsers support window.innerHeight while some support document.documentElement.clientHeight and some support both. Thus we can use the same fallback approach here as well.

(window.innerHeight || document.documentElement.clientHeight)

Combining this together, We can check if the element is in the viewport like this.

const isInViewport = function (elem) {
     const bounding = elem.getBoundingClientRect();
     return (
       bounding.top >= 0 &&
       bounding.left >= 0 &&
       bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
       bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
};

Now we can use this helper method on each element to determine if they are within the viewport or not.

First, the sub-problem is solved, now let's try to solve the second one.


Call a function when user stops scrolling or any other interactions for sometime.

For this we can use the debouncing technique.

Debouncing is a method or a way to execute a function when it is made sure that no further repeated event will be triggered in a given frame of time.

In simple words if the scroll event is not triggered again within the specified time (assume 5 seconds) then only invoke the function. This is implemented using the setTimeout timer function.

I have already explained two different variations of debouncing.

1. Normal debouncing.
2. Debouncing with Immediate flag.

Based on the use, we can choose any one of them. For this problem, we will go with the normal one.

const debounce = (func, delay) => {
  let inDebounce;
  return function() {
    const context = this;
    const args = arguments;
    clearTimeout(inDebounce);
    inDebounce = setTimeout(() => func.apply(context, args), delay);
  };
};

This takes care of our second sub-problem. Now, let's put this all together and create the final solution.


Putting everything together

Let's put each piece in the place to get the final picture.

Select all the elements/products/articles/blocks of the DOM which you want to store in the API call, as I have assigned blocks class to each of them, I will query select them all and store in a variable.

// Get all the products
const blocks = document.querySelectorAll('.blocks');

The next thing is we will need a function that will check which elements are within the viewport and then take an appropriate course of action thereafter.

// Helper function to check if element is in viewport
const isInViewport = function (elem) {
    const bounding = elem.getBoundingClientRect();
    return (
        bounding.top >= 0 &&
        bounding.left >= 0 &&
        bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
};

// Function which will make the API call
const getBlocks = function () {
      blocks.forEach((block) => {
        if (isInViewport(block)) {
          //make API call here
          console.log(block.innerText);
        }
  });
  
  // add a space
  console.log(" ");
 }

Call this function after debouncing the scroll event for which we will have to assign an event listener.

// Debounce a function call
const debounce = (func, delay) => {
    let inDebounce;
    return function() {
        const context = this;
        const args = arguments;
        clearTimeout(inDebounce);
        inDebounce = setTimeout(() => func.apply(context, args), delay);
    };
};

// Assign the event listener
window.addEventListener('scroll', debounce(getBlocks, 1000), false);

And that's it, we are done.

We can see the working of this in this image.

Capture post when user stops scrolling on web page.


Complete code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .wrapper{
            display: flex;
            align-items: center;
            justify-content: center;
            flex-wrap: wrap;
        }

        .blocks{
            flex: 1 300px;
            height: 300px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            margin: 5px;
            background: red;
            font-size: 40px;
            color: #fff;
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <div class="blocks">1</div>
        <div class="blocks">2</div>
        <div class="blocks">3</div>
        <div class="blocks">4</div>
        <div class="blocks">5</div>
        <div class="blocks">6</div>
        <div class="blocks">7</div>
        <div class="blocks">8</div>
        <div class="blocks">9</div>
        <div class="blocks">10</div>
        <div class="blocks">11</div>
        <div class="blocks">12</div>
        <div class="blocks">13</div>
        <div class="blocks">14</div>
        <div class="blocks">15</div>
        <div class="blocks">16</div>
        <div class="blocks">17</div>
        <div class="blocks">18</div>
        <div class="blocks">19</div>
        <div class="blocks">20</div>
        <div class="blocks">21</div>
        <div class="blocks">22</div>
        <div class="blocks">23</div>
        <div class="blocks">24</div>
        <div class="blocks">25</div>
        <div class="blocks">26</div>
        <div class="blocks">27</div>
    </div>

    <script>
        // Helper function to check if element is in viewport
        const isInViewport = function (elem) {
            const bounding = elem.getBoundingClientRect();
            return (
                bounding.top >= 0 &&
                bounding.left >= 0 &&
                bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
                bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
            );
        };

        // Debounce a function call
        const debounce = (func, delay) => {
            let inDebounce;
            return function() {
                const context = this;
                const args = arguments;
                clearTimeout(inDebounce);
                inDebounce = setTimeout(() => func.apply(context, args), delay);
            };
        };

        // Function which will make the API call
        const getBlocks = function () {
            blocks.forEach((block) => {
                if (isInViewport(block)) {
                    console.log(block.innerText);
                }
            });

            console.log(" ");
        }

        // Get all the products
        const blocks = document.querySelectorAll('.blocks');

        // Assign the event listener
        window.addEventListener('scroll', debounce(getBlocks, 1000), false);
    </script>
</body>
</html>