What a slow Chrome trace told us about a 2,000-product catalogue page
A client flagged that their largest product listing page was unresponsive on desktop and crashing on mobile. Smaller catalogues worked fine. We opened a Chrome trace, found a 14-second blocking task, and then another, and then another. Here's what we found, and what shipped.
A few weeks back a client flagged something we'd half-suspected for a while: their largest product listing pages were rough when showing. Scrolling stuttered, the page took a beat to respond to anything, and on mobile (across browsers) the tab would simply die partway down the catalogue. On one large category page with 1,948 listings, most mobile users got about 30% of the way down before the page reloaded itself.
The same template on smaller catalogues, a few dozen items, performed fine. The problem only showed up at scale. Desktop wasn't crashing, but it wasn't pleasant either: the page took several seconds to become responsive after load, scrolling stuttered, and filtering felt sluggish.
So we opened a Chrome Performance trace, recorded a scroll, and looked at where the time was going. The numbers were ugly in a useful way.
| First trace | |
|---|---|
| Worst single blocking task | 14,117 ms |
| Second-worst blocking task | 10,471 ms |
Total Layout time per scroll session |
1,182 ms |
| Style recalculations during one task | 318 |
A 14-second blocking task means the browser's main thread couldn't respond to clicks, scrolls, or anything else for 14 seconds. The user correctly described it as "unresponsive". With numbers like those, there's no question about whether the page is slow, only about where the time has gone. So we started clicking into the longest tasks.
Fix 1: scroll handler doing far too much work
The first thing that jumped out wasn't the 14-second monster, but a less dramatic culprit affecting general scroll smoothness. The sticky filter bar at the top of the page had a scroll handler that ran on every scroll event with no throttling. Inside it, the handler:
- Re-queried seven jQuery selectors from scratch on every tick
- Interleaved layout reads (
.position(),.height()) with style writes (.css({...})), forcing the browser to recalculate layout multiple times per scroll event
On a small page this would have been fine. On a 95,000-element layout tree, each forced layout pass cost real time, and at 60+ scroll events a second it added up to noticeable jank.
The fix was a one-page change: wrap the handler in the existing throttle helper (one execution per frame), cache the jQuery selectors at module scope, batch all the reads before any of the writes. Nothing clever, but scroll handlers are a place where small inefficiencies multiply.
Fix 2: a request storm on fast scrolls
Next we found that scrolling fast on the catalogue page fired an API request for every product card the viewport swept past. Live data (the dynamic bit of the product card) was loaded lazily via an IntersectionObserver, which is the right approach. The trouble was, the callback ran its work immediately on every intersection event with no debouncing.
So if a user scrolled past 200 product cards, the page fired 200 requests for data the user would never see, all queued up against the items the user had actually stopped on.
We wrapped the callback in a 250ms debounce (using a helper already present in the codebase). When the user stops scrolling for 250ms, we fetch data only for the product cards still in the viewport. Items scrolled past silently get skipped, and re-fetched on the way back up if needed.
If you're lazy-loading prices, stock levels, or reviews on a long PLP, the same pattern applies.
Fix 3: the 14-second task explained
Now to the big one. Drilling into the 14-second blocking task, the breakdown was striking:
- 318
UpdateLayoutTree(style recalculation) events - 10.3 seconds of total style recalc time
- All 318 events traced back to a single function:
renderAppliedFiltersin our filter sidebar code
renderAppliedFilters loops over the available filter values and toggles visibility of each. It does this with jQuery's .show() and .hide(), called several hundred times per render.
Here's the catch. jQuery UI silently monkey-patches .show() and .hide() when it's loaded. The override is designed to support jQuery UI's effects API, and it works by reading the element's computed style before changing it: a synchronous getComputedStyle() call. That read forces the browser to flush all pending style invalidations across the entire document.
The flush cost scales with total document size, not with the element being shown. On a small catalogue those calls were essentially free. On a 1,948-product page the same calls each cost ~32 ms, and there were 318 of them. Multiply: ten seconds.
That's why the bug only appeared at scale. On the small catalogues the function did the same work, just over a document tiny enough that the cost was invisible. Past a certain DOM size the per-call flush became expensive, and the bug went from imperceptible to ten-seconds-on-the-main-thread.
The fix:
elem.css('display', ''); // instead of .show()
elem.css('display', 'none'); // instead of .hide()
.css() doesn't go through jQuery UI's effects pipeline. It writes directly. No synchronous read, no flush.
The result on the next trace:
| Before | After | |
|---|---|---|
Style recalcs in renderAppliedFilters |
318 | 1 |
| Style recalc time in that task | 10.3 s | ~0 |
| Worst task in the trace | 14.1 s | 9.5 s |
The 14-second monster was gone. But the trace now had a new worst offender: a 9.5-second pageshow event.
Fix 4: nine seconds inside one function
A pageshow event firing for 9.3 seconds is unusual. The event itself is just a notification that the page has been shown, but on this site it triggers the live-data app's initialisation. Drilling in:
- 96.6% of CPU samples (11,998 out of 12,414) were inside a single function called
getCurrentBids - That function does one apparently innocent thing: it loops over every
.js-current-bidelement on the page and registers each with anIntersectionObserver. On a 1,948-product page that's about 2,000 iterations. At ~9 ms per iteration, you get 9 seconds.
But why was each iteration costing 9 milliseconds? The loop body was trivial: a class add, an attribute read, an observer registration. None of those should be that slow.
The answer turned out to be a JavaScript trap that doesn't show up in test environments.
app.dom.bidding.currentBids = document.getElementsByClassName('js-current-bid');
// ...
for (let bid of app.dom.bidding.currentBids) { ... }
getElementsByClassName returns a live HTMLCollection, not a static array. Every time you access [i], the browser re-checks the DOM forward to find the next matching element. On a small DOM that's negligible. On a 270,000-node DOM (we had a lot of HTML), each lookup walks a substantial fraction of the tree, turning the loop from O(n) into O(n × DOM size).
Again, this is why small catalogues were fine. Same loop, same code, but on a DOM small enough that the per-iteration cost was a rounding error. Multiply both axes and you go from "fast" to "nine seconds".
The fix is a single line, snapshot the live collection to a plain array first:
const currentBidsSnapshot = Array.from(app.dom.bidding.currentBids);
for (let bid of currentBidsSnapshot) { ... }
The next trace:
| Before | After | |
|---|---|---|
pageshow event handler |
9,286 ms | 106 ms |
A 99% reduction from a one-line change. The most dramatic single fix in the whole investigation.
Fix 5: repeated DOM scans in the filter renderer
The same renderAppliedFilters from Fix 3 came back to bite us. With the style-recalc cost eliminated, a different shape of slowness in the same function emerged: a 2.98-second blocking task in which 76% of CPU samples were inside document.querySelectorAll.
Looking at the function more carefully, we found this pattern:
allResults.forEach(result => {
let propertyElements = $(`
[data-filter-property][data-filter-name="${result.propertyName}"][data-filter-value="${result.valueName}"],
[data-filter-plate][data-filter-name="${result.propertyName}"][data-filter-value="${result.valueName}"]
`);
propertyElements.each(function() { ... });
});
For every filter result (and there were 600+ on a large catalogue) the function ran a fresh document.querySelectorAll against the entire document. Each scan walked the whole 270,000-node DOM looking for matches. The same set of filter elements got re-discovered hundreds of times.
We rewrote it to walk the filter elements once, looking up against a Map built from the results upfront:
const resultMap = new Map();
for (const result of allResults) {
resultMap.set(`${result.propertyName}|${result.valueName}`, result);
}
allPropertyElements.each(function() {
const propertyName = this.getAttribute('data-filter-name');
const valueName = this.getAttribute('data-filter-value');
const result = resultMap.get(`${propertyName}|${valueName}`);
// ... handle this element
});
O(1) lookups per element, one document scan total. The 2.98-second task disappeared from the next trace.
Fixes 6 and 7: the same trap, two more places
Once you've seen the live HTMLCollection trap once, you start looking for it everywhere. We found it in two more spots: updateCurrentBid and updateTotalBids. Both get called for every incoming data update, and both iterated live HTMLCollections.
Under quiet conditions you'd never notice. Under a burst of live updates on a busy page, the same O(n × DOM size) cost would compound across every update. Both got the same Array.from snapshot treatment.
The same risk exists on any e-commerce site that pushes live updates to product cards: real-time stock counters, flash-sale price tickers, "X people viewing this" badges. If you're iterating a live collection on every websocket message, you've got a latent O(n × DOM size) bug waiting for the catalogue to grow.
Where we stood after the JavaScript fixes
We re-captured a trace. The all-products view loaded fast, scrolling on desktop was smooth, and our worst remaining own-code task was inside acceptable limits. Desktop performance was now genuinely good rather than just "better".
On mobile, though, the crashes hadn't fully gone away. The client reported they could scroll about 70% of the way down the catalogue before the tab killed itself, up from 30% before any work began. Real progress, but not the end of the story.
Why mobile was still crashing
A heap snapshot answered this. JavaScript heap was modest, about 30 MB. But native renderer memory was huge:
- 2.88 million
InternalNodeentries: 180 MB of tree linkage data alone - 138,000 text nodes
- 6,042 inline SVG icons, contributing ~30 MB of supporting structures
- ~250 MB total native memory before the user did anything
Mobile browsers operate under per-tab memory caps that are much lower than desktop, typically in the 200-400 MB range depending on the device and browser. When a tab exceeds the cap, the renderer process gets killed and the tab reloads. We were at or above that cap just sitting on the page. Add aggressive image decoding during fast scroll (browsers decode product images ahead of the viewport on the assumption you're going to keep scrolling) and you push past the cap. Tab dies, page reloads.
The JavaScript fixes had reduced the time the page spent in its peak-hydration state, but they hadn't reduced its baseline memory footprint. We needed a different lever.
Fix 8: content-visibility: auto
Modern browsers support a CSS property called content-visibility: auto. It tells the browser:
This element's contents are off-screen. You don't need to lay them out, paint them, or decode their images until the user scrolls them near.
You pair it with contain-intrinsic-size, which tells the browser roughly how tall the element will be when it does render, so the scrollbar behaves correctly.
.ui-listings__lot {
content-visibility: auto;
contain-intrinsic-size: 0 410px; /* mobile placeholder height */
}
@include breakpoint(small) {
.ui-listings__lot {
contain-intrinsic-size: 0 462px; /* desktop */
}
}
The effect on the next trace was extraordinary:
| Before | After | |
|---|---|---|
| DOM nodes the renderer was tracking | 552,696 | 282,470 |
Layout totalObjects (per event, during scroll) | ~94,200 | 3,146 |
RasterTask total during a scroll session | 1,001 ms | 274 ms |
ImageDecodeTask count during scroll | 181 | 32 |
PaintImage count during scroll | 3,141 | 913 |
A 97% reduction in the size of the layout tree the browser had to walk on every paint cycle. 82% fewer image decodes. 73% less raster work. Off-screen product cards effectively didn't exist from a rendering perspective until the user scrolled them into view.
For good measure we also added decoding="async" to the product card image, which tells the browser it can decode the JPEG off the main thread.
The client tested again and reported they could scroll the full catalogue on mobile for the first time.
That was the headline result. What had previously been an unusable page on phones for the biggest catalogues was now scrollable end to end. Desktop performance was also markedly better on the same change: faster scroll, fewer paint pauses, lower memory pressure for users with many tabs open.
The headline numbers
End-to-end, across all the fixes:
| Before | After | |
|---|---|---|
| Worst blocking task in a trace | 14,117 ms | < 200 ms |
pageshow event handler | 9,286 ms | 114 ms |
| Filter rendering style recalcs | 318 per invocation | 1 |
| Layout tree size walked per paint | ~94,200 objects | ~3,146 objects |
| Mobile scroll reach on the largest catalogues | 30% → 70% | full page |
| Image decodes during a 16s scroll | 181 | 32 |
The full set of changes was seven targeted fixes plus the CSS change, spread across the live-data code, filter rendering, and the product card template. Most were five-line patches or less. The two larger changes were clean rewrites that preserved every behaviour of the originals. We made a point of not shipping anything we couldn't reason about end to end.
Smaller catalogues, the ones already performing adequately, benefit from every one of these changes too, just less dramatically. None of the fixes regress anything on a small DOM; they just don't add much when there wasn't much to add.
Lessons worth taking forward
A few takeaways from this one that generalise.
Large catalogues and small catalogues have different bug surfaces. Most of the issues we found were present on every page render, on every catalogue. They simply didn't matter until the data set was large enough to make their cost visible. If your code path scales with data size, it has to be tested on the largest data your users will hand it. A unit test against a list of three products won't surface an O(n × DOM size) problem. If you run flash sales, large category pages, or seasonal catalogues that occasionally balloon, test against those, not against your demo data.
Live HTMLCollections are dangerous on large DOMs. Anything returned by getElementsByClassName, getElementsByTagName, or document.forms is a live view of the DOM that re-checks on every index access. Iterating one inside a loop is O(n × DOM size). The fix is a one-line Array.from(...) snapshot. If you're iterating a collection in a hot path, never assume it behaves like an array.
jQuery UI changes the meaning of common jQuery methods. Just loading the library on a page silently overrides .show(), .hide(), :visible, and :hidden with versions that perform synchronous layout reads. On large DOMs these become expensive. If you have jQuery UI in your stack and you're using .show()/.hide() in loops or hot paths, switch to .css('display', '') and .css('display', 'none'), or class toggles.
document.querySelectorAll is not free. It walks the document looking for matches. Calling it 600 times in a function will cost you real time on a large DOM. Pre-build a Map from any data you'd be matching against, walk your elements once, look up.
Mobile crashes are usually a memory story, not a JavaScript story. When a mobile browser kills a tab, it's almost always because the renderer process exceeded a per-tab memory cap. That limit measures native renderer memory (DOM, decoded images, GPU layers), not your JavaScript objects. A heap of 30 MB is fine; a layout tree of 95,000 elements with thousands of decoded product images is not. The fix is almost never "use less JS", it's "give the browser less to render at any one moment".
content-visibility: auto is the single biggest win for long product listings. If your page is a long list of items where most are off-screen at any given moment, this one CSS property combined with contain-intrinsic-size can cut the browser's working set dramatically. Older browsers ignore it silently, so there's no regression risk to adding it. Hard to overstate how useful this is for any product listing page, category page, search results page, or feed.
A "long task" in DevTools isn't necessarily JavaScript. Tasks reported as taking several seconds are often dominated by compositor or raster work, with the main thread itself idle. Always check CPU sample density inside a long task before assuming you have a JavaScript problem. The CPU profile in DevTools is the truth; the timeline view is the headline.
Closing thought
Performance work on a large existing codebase is rarely about one heroic change. It's about reading traces carefully, finding the actual hot function rather than the suspected one, and shipping targeted fixes that add up.
There are still bigger architectural levers we could pull: virtualised list rendering, smaller image variants for mobile, deferred third-party scripts. But the page now does the thing the user asked for. It loads fast, scrolls smoothly, and doesn't crash on their phone. That'll do for now.
If your catalogue is large and any of this sounds painfully familiar, the same traps will be eating the same seconds on your page. Worth a trace.