Infinite Scroll Made the Collection Page Slower With Every Scroll
By the fifth batch the page held 240 product cards and dropped frames on every scroll. The DOM node count was the metric nobody was watching.
A collection page that loaded fast, scrolled smoothly, and then degraded until scrolling felt like dragging something heavy. By the time a customer had loaded five batches, frame rate was in the low twenties on a mid range phone.
The initial load metrics were all green. Nothing in Lighthouse catches this, because Lighthouse does not scroll.
The symptom, measured
I recorded a scroll after five batches with the performance panel and got 24 fps with long purple bars, which is layout and style recalculation rather than script.
The number that explained it:
console.log('nodes', document.getElementsByTagName('*').length)
console.log('cards', document.querySelectorAll('.product-card').length)
console.log('listeners on cards',
document.querySelectorAll('.product-card [data-quick-add]').length)
nodes 11840
cards 240
listeners on cards 240
Nearly twelve thousand DOM nodes. Chrome's own guidance is to stay under about 1,500, and the reason is that style recalculation and layout are proportional to node count. Every scroll event that triggered a recalculation was walking a tree eight times larger than it should have been.
Tested on Dawn 15, Chrome 133, mid range Android profile.
Hypothesis one: the fetch is slow
Wrong, and easy to eliminate. Each batch fetch returned in about 200 ms and the appending was not on the critical path of scrolling.
The slowness was not when loading a batch. It was continuous, after loading, which pointed away from the network entirely.
Hypothesis two: the images
Also wrong, though they contributed. 240 lazy loaded images that have all entered the viewport are 240 decoded bitmaps in memory. On a constrained device that is real memory pressure, and I did see the tab's memory climb past 400 MB.
But memory pressure does not produce the specific pattern of long style recalculation bars. It produces jank of a different shape, and occasional crashes, which the merchant had also reported.
Hypothesis three: too many event listeners
Getting warmer. 240 individual quick-add listeners is wasteful, and the fix is event delegation:
// one listener, not one per card
document.querySelector('#product-grid').addEventListener('click', (e) => {
const btn = e.target.closest('[data-quick-add]')
if (!btn) return
quickAdd(btn.dataset.variantId)
})
That is correct and worth doing. It did not fix the frame rate, because listeners cost memory rather than layout time.
The actual cause: node count and CSS containment
The bars were style recalculation, and style recalculation cost scales with how much of the tree the browser must re-evaluate. With no containment, a change anywhere can invalidate layout for the whole grid.
Two fixes, in order of impact.
Tell the browser each card is independent.
.product-card {
content-visibility: auto;
contain-intrinsic-size: auto 420px;
contain: layout style paint;
}
content-visibility: auto skips rendering work entirely for cards that are off screen. The browser still keeps them in the DOM and still knows they exist, but it does not compute layout, style or paint for them until they approach the viewport.
contain-intrinsic-size is the essential companion. Without it, off screen cards collapse to zero height, the scrollbar jumps around, and scroll position becomes unstable. The value is your card's approximate rendered height, and getting it roughly right is enough.
This one property took the scroll back to a steady 58 fps with all 240 cards still in the DOM. It is the highest leverage CSS property for long lists and it is still not widely used.
Cap the total. Containment makes a large DOM cheap; it does not make it free, and memory still grows. I capped the grid at 120 cards, removing the oldest batch when a new one is appended, which is a simple form of windowing:
const MAX = 120
function appendBatch(nodes) {
grid.append(...nodes)
while (grid.children.length > MAX) grid.firstElementChild.remove()
}
This breaks the back button and scroll restoration unless you handle it, which is one of the reasons I now think infinite scroll is usually the wrong choice.
What I would actually recommend
Having fixed this, my honest position is that infinite scroll on a collection page is rarely worth it.
It breaks the back button. A customer scrolls to product 90, taps in, comes back, and lands at the top. This is the single largest usability cost and it directly harms conversion. Fixing it properly requires storing scroll position and re-fetching batches on return, which is a lot of machinery.
It hides the footer. Shipping information, size guides and policy links become unreachable, and those pages matter for trust and for internal linking.
It has no shareable URL. Nobody can link to a position in an infinite list.
Search engines see only the first batch unless you also render paginated links, so your deeper products may go undiscovered.
Paginated collections with a sensible page size, or a "load more" button, give you most of the browsing benefit with none of these problems. A button is also honest: the customer chooses to load more, so the cost is attributable to an interaction rather than accumulating silently.
If you keep infinite scroll, keep the paginated URLs working underneath it and render rel="next" links, so the structure survives for crawlers and for customers who arrive on page three.
What I changed
{%- comment -%} paginate normally, enhance progressively {%- endcomment -%}
{% paginate collection.products by 24 %}
<div id="product-grid" data-total="{{ collection.products_count }}">
{% render 'product-card' for collection.products as product %}
</div>
{%- if paginate.next -%}
<a href="{{ paginate.next.url }}" id="load-more" rel="next">
Load more products
</a>
{%- endif -%}
{% endpaginate %}
The link is a real link. Without JavaScript it navigates to page two, which is a working store. With JavaScript it fetches and appends, and the href updates to the following page. Progressive enhancement here costs almost nothing and it means the render for form handles the server side efficiently at the same time.
Prevention
Watch DOM node count as a metric. Log it after each batch. If it passes 3,000 you have a problem coming, and it will only show up on devices you do not test on.
Use content-visibility: auto on any repeated card. It is one line and it is the difference between a large DOM being expensive and being nearly free.
Delegate events on lists. One listener on the container, always.
Test scrolling, not just loading. Every automated performance tool measures page load. Nobody measures the fifth batch, which is where real customers spend their time.
Default to pagination. Infinite scroll is a feature with real costs that are usually paid by mobile users on the worst devices, which is the same population that suffers from oversized images and every other invisible regression.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud