Back to blog

Building Infinite Scroll with Virtual Scroll in Ember: A Step-by-Step Guide

Sanket Munot
ember javascript frontend performance virtual-scroll infinite-scroll webdev ui-optimization state-management opensource

Ember has a few addons that add support for virtual scroll in your application, some reasons to write it from scratch are:
To find an exact addon that supports the version of your application is cumbersome, to get that addon running is more tiresome :)
To support dynamic heights without knowing the number of nodes.


Lets start with a brief on infinite scroll before moving to virtual scroll

An infinite scroll provides a seamless experience on websites with an increased number of pages such as Instagram or an e-commerce website, it loads more data by keeping track of how far a user has scrolled down the page.

Infinite scroll removes the necessity of using pagination buttons or load more buttons. This makes it easier for the user by eliminating the load time of new pages.
While infinite scroll is so intuitive it must be used with caution, Why?
Infinite scroll can render hefty amounts of content on the browser which can cause performance issues because it takes memory and CPU power to render everything on the screen simultaneously.
Now to help with the performance we load only a subset of elements that are visible on the screen or viewport, this way the user feels he is scrolling an indefinite list of items, while the list of items is being popped at the top and new items are pushed at the bottom.


How did we implement it?

Infinite scroll

Before moving to virtual scroll, we start with a basic Infinite scroll ->

<div id="basic-infinite-scroll">
    {{yield infiniteRecords}}
</div>
didRender() {
    this._super(...arguments);
    this.addInfiniteListener();
  },

addInfiniteListener() {
    const scrollListener = (e) => {
      const {
        scrollTop,
        offsetHeight,
        scrollHeight,
      } = e.target;

      const {
        scrolledTill
      } = getProperties(this, 'scrolledTill')
      if (Math.abs(scrollTop - scrolledTill) > 50)
        set(this, 'scrolledTill', scrollTop)

      const scrollThreshold = (scrollHeight - offsetHeight) * (0.7)
      if (scrollTop > scrollThreshold && get(this, 'hasNext') && !get(this, 'nextLoading')) {
        this.sendAction('next')
        wrapper.removeEventListener('scroll', scrollListener)
      }
    }

    const wrapper = document.getElementById('basic-infinite-scroll');
    wrapper.addEventListener('scroll', scrollListener)
  },

Add an infinite listener on basic-infinite-scroll div, and listen to changes in the viewport using the didRender hook. Notice we remove the listener once the next action to make a request call for the next page has been made. Once the new page is loaded we have a fresh infinite listener again.

The yielded infiniteRecords is to be used as a higher-order component with each loop to render all the records into the hbs template.

Note: scrolledTill is the amount of height scrolled in the viewport, it is used in further sections during virtual scroll.

PS: There are other ways to make an infinite scroll like mutation observer can also be used.


Ramp Infinite scroll to Virtual scroll

The basic structure of what we are trying to achieve

Before starting we do a basic transformation on the hash of the records which is further used while caching heights. We use buildRecordsin didReceiveAttrs, which makes sure to rerun every time records change.

buildRecords() {
  const records = get(this, 'records')
  records.forEach((element, idx) => {
    set(element, 'id', idx)
  });
}

didReceiveAttrs() {
  this._super(...arguments);
  this.buildRecords()
}

Height plays a crucial role since nodes have dynamic heights and infinite scroll also works on how much the page is scrolled?, we try to cache the heights of nodes rendered on the screen, thus the sum of all heights would be the viewport height.

A blocker here is unless a node is rendered on the screen its height is unavailable, to solve this we provide a tolerance height which is to be used until the original height is calculated.

didRender() {
  this._super(...arguments);
  this.addInfiniteListener()
  this.recalculateHeights()
},
cacheRecordsWithHeight: {},
recalculateHeights() {
  const {
    cacheRecordsWithHeight,
    records
  } = getProperties(this, 'cacheRecordsWithHeight', 'records');

  let cache = {}
  for (let index = 0; index < records.length; index++) {
    if (cacheRecordsWithHeight[index] && cacheRecordsWithHeight[index].originalHeight) {
      cache[index] = cacheRecordsWithHeight[index]
    } else {
      const row = document.querySelector(`#virtual-record-${index}`)
      cache[index] = {
        originalHeight: row ? row.clientHeight : false,
        toleranceHeight: 200,
      }
    }
  }
  set(this, 'cacheRecordsWithHeight', cache)
},

Combining the sum of heights with padding-top and scroll is a technique used to create the illusion of the presence of nodes above the visible area to the user.

paddingTop: computed('startNode', {
  get() {
    const {
      cacheRecordsWithHeight,
      startNode
    } = getProperties(this,
      'cacheRecordsWithHeight',
      'startNode');
    return Object.keys(cacheRecordsWithHeight)
    .slice(0,startNode)
    .reduce((sum, curr) => {
      return sum += cacheRecordsWithHeight[curr].originalHeight||cacheRecordsWithHeight[curr].toleranceHeight
    }, 0) 
  }
})

Seems enough, What else?

Now that everything is in place only thing that remains is startNode & endNode

startNode: computed('scrolledTill', {
  get() {
    const {
      scrolledTill,
      cacheRecordsWithHeight,
    } = getProperties(this,
      'scrolledTill',
      'cacheRecordsWithHeight',
      );
    let sum = 0;
    const start = Object.keys(cacheRecordsWithHeight).find(
      (record) => {
        sum += cacheRecordsWithHeight[record].originalHeight
        return sum > scrolledTill
      }
    )
    return parseInt(start) || 0
  }
}),
endNode: computed('startNode', 'records', {
  get() {
    const { 
             startNode,
             records,
             viewportBuffer,
             bufferNodes 
           } = getProperties(this, 
                            'startNode',
                            'records',
                            'viewportBuffer',
                            'bufferNodes');
    return Math.min(records.length, startNode + viewportBuffer + bufferNodes)
  }
}),
currentView: computed('startNode', 'endNode', {
  get() {
    const { records, startNode, endNode } = getProperties(this, 'records', 'startNode', 'endNode')
    return records.slice(startNode, endNode)
  }
})
<div id="basic-virtual-scroll" style="padding-top:{{paddingTop}}px">
    {{yield currentView}}
</div>

Let’s consolidate everything together.

virtualScroll-component.js

import Ember from 'ember';
import BaseComponent from 'frontend/components/base-component';

const {
  get,
  getProperties,
  set,
  computed
} = Ember;

export default BaseComponent.extend({
  scrolledTill: 0,
  bufferNodes:2,
  currentView: computed('startNode', 'endNode', {
    get() {
      const { records, startNode, endNode } = getProperties(this, 'records', 'startNode', 'endNode')
      return records.slice(startNode, endNode)
    }
  }),
  startNode: computed('scrolledTill', {
    get() {
      const {
        scrolledTill,
        cacheRecordsWithHeight,
      } = getProperties(this,
        'scrolledTill',
        'cacheRecordsWithHeight',
        );
      let sum = 0;
      const start = Object.keys(cacheRecordsWithHeight).find(
        (record) => {
          sum += cacheRecordsWithHeight[record].originalHeight
          return sum > scrolledTill
        }
      )
      return parseInt(start) || 0
    }
  }),
  endNode: computed('startNode', 'records', {
    get() {
      const { startNode, records, viewportBuffer, bufferNodes } = getProperties(this, 'startNode', 'records', 'viewportBuffer', 'bufferNodes')
      return Math.min(records.length, startNode + viewportBuffer + bufferNodes)
    }
  }),

  paddingTop: computed('startNode', {
    get() {
      const {
        cacheRecordsWithHeight,
        startNode
      } = getProperties(this,
        'cacheRecordsWithHeight',
        'startNode');
      return Object.keys(cacheRecordsWithHeight)
      .slice(0,startNode)
      .reduce((sum, curr) => {
        return sum += cacheRecordsWithHeight[curr].originalHeight||cacheRecordsWithHeight[curr].toleranceHeight
      }, 0) 
    }
  }),

  init() {
    this._super(...arguments);
  },

  didReceiveAttrs() {
    this._super(...arguments);
    this.buildRecords()
  },

  didRender() {
    this._super(...arguments);
    this.addInfiniteListener()
    this.recalculateHeights()
  },

  addInfiniteListener() {
    const scrollListener = (e) => {
      const {
        scrollTop,
        offsetHeight,
        scrollHeight,
      } = e.target;

      const {
        scrolledTill
      } = getProperties(this, 'scrolledTill')
      if (Math.abs(scrollTop - scrolledTill) > 50)
        set(this, 'scrolledTill', scrollTop)

      const scrollThreshold = (scrollHeight - offsetHeight) * (0.7)
      if (scrollTop > scrollThreshold && get(this, 'hasNext') && !get(this, 'nextLoading')) {
        this.sendAction('next')
        wrapper.removeEventListener('scroll', scrollListener)
      }
    }

    const wrapper = document.getElementById('basic-virtual-scroll');
    wrapper.addEventListener('scroll', scrollListener)
  },

  cacheRecordsWithHeight: {},
  recalculateHeights() {
    const {
      cacheRecordsWithHeight,
      records
    } = getProperties(this, 'cacheRecordsWithHeight', 'records');

    let cache = {}
    for (let index = 0; index < records.length; index++) {
      if (cacheRecordsWithHeight[index] && cacheRecordsWithHeight[index].originalHeight) {
        cache[index] = cacheRecordsWithHeight[index]
      } else {
        const row = document.querySelector(`#virtual-record-${index}`)
        cache[index] = {
          originalHeight: row ? row.clientHeight : false,
          toleranceHeight: 200,
        }
      }
    }
    set(this, 'cacheRecordsWithHeight', cache)
  },

  buildRecords() {
    const records = get(this, 'records')
    records.forEach((element, idx) => {
      set(element, 'id', idx)
    });
  },

})

virtualScroll-template.js

<div id="basic-virtual-scroll" style="padding-top:{{paddingTop}}px">
    {{yield currentView}}
</div>

Usage of component

{{#basic-virtual-scroll 
    next=(action 'nextPage')
    hasNext=loadMore
    nextLoading=fetchTimelineFeeds.isRunning
    records=postCardsData
    viewportBuffer=5
    as |virtualRecords|
    }}
  {{#each virtualRecords as |virtualRecord index|}}
    {{node virtualRecord=virtualRecord}}
  {{/each}}
{{/basic-infinite-scroll}}
© 2026 Sanket Munot