Rebuilding an Angular Carousel

The production Datadog screenshots made the problem obvious before the code did. They showed the carousel was slow enough to justify a deeper look.

We then rebuilt the behaviour in a minimal Angular repo so we could measure each migration step in isolation and see which change actually moved the needle.

The fix happened in two steps: 

  • stop using JavaScript for layout work the browser can already do well 

  • render less UI

Production BEFORE optimisation:

INP before

Production AFTER optimisation:

INP after

The problem was architectural

The old carousel was built around NguCarousel, which bundled layout behavior and interaction behavior at runtime. That worked, but it also meant the component kept paying layout coordination costs on every interaction.

Modern browsers already know how to do most of this job. Horizontal scrolling, snap points, and responsive card sizing do not need a TypeScript middle manager. Asking JavaScript to coordinate them anyway is like holding a meeting to decide where every chair should go.

The important detail is that this was not only a rendering problem. It started as a layout architecture problem. The legacy screenshots showed repeated forced reflow pressure, which is a strong hint that the system was doing too much runtime layout work.

This was the old shape of the component:


    
        
    

    
    

We reproduced the issue without production code

Before changing architecture, we mirrored the behavior in a minimal Angular repo. Production environments are noisy. If the same pattern shows up in a stripped-down reproduction, the problem is probably structural.

The repo preserved the important parts: the category flow, the tile layout, and the navigation model. This gave us a clean baseline for comparing three stages of the same component:

  1. The old JS-driven carousel.

  2. A modern CSS-first carousel.

  3. A virtualized version of that modern carousel.

The screenshots were enough to show the pattern across all three stages without burying the point in trace-level detail.

inp old

INP for the old carousel


CSS removed the first bottleneck

The first rewrite moved layout responsibility into CSS. The goal was to stop recalculating behavior in JavaScript when the browser already had native primitives for it.

The modern version kept TypeScript for controls and state, but moved paging and sizing into CSS with scroll-snap and simpler layout rules. Instead of asking JavaScript how wide each tile should be and how the carousel should advance, the component described the layout and let the browser do the rest.



.modern-carousel {
    display: flex;
    container-type: inline-size;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
}

.modern-carousel-item {
    scroll-snap-align: start;
    width: calc(100cqw / 2 - 16px);
}

scroll-snap gave the browser native paging behavior. The simpler CSS replaced a chunk of JavaScript layout coordination with component-owned layout rules. This was not magic. We removed one class of runtime work, and INP dropped from 582 ms to 428 ms.

The CSS-first version still had layout tasks. That is normal. The point is that it no longer showed the same forced-reflow pattern as the legacy implementation.

inp modern

INP for the modern carousel


Virtualization finished the job

After the CSS rewrite, the next bottleneck became easier to see. Layout coordination was smaller, but the component was still rendering more active UI than necessary.

That is where RxVirtualView came in. The final version kept the same CSS-first structure and reduced how many carousel items needed to stay active during interaction.

This second step targeted rendering cost, not layout cost. If the first fix teaches the browser to do the layout job natively, the second fix stops asking it to keep every tile alive at once.

That pushed INP from 428 ms to 328 ms.

inp virtualized

INP for the virtualized modern carousel

Results at a glance

Category

Result

INP

Improved from 582 ms to 428 ms, then to 328 ms

Layout behavior

The old version showed repeated forced reflow pressure; the CSS-first version removed that pattern

Rendering cost

RxVirtualView reduced active UI work on top of the CSS-first layout

Architecture

The component moved from JS-managed layout to native CSS layout, then to CSS plus virtualization


How to apply the same pattern elsewhere

This sequence is useful outside carousels. Any time a component feels heavy, we should check these questions in order:

  1. Is JavaScript managing layout decisions that CSS can express directly?

  2. If the layout is fixed, am I still rendering more active UI than the interaction actually needs?

  3. Am I measuring both stages separately, or collapsing everything into one vague “performance issue” bucket?

That order matters because it prevents premature optimization. There is no point virtualizing a component whose architecture still forces the browser into avoidable layout work. Fix the geometry first. Then reduce the amount of live UI.


The best win here was architectural. First, let the browser do layout work it already knows how to do. Then optimize how much UI you ask it to render. If we had started with smaller tweaks inside the old runtime model, we would have been polishing the wrong bottleneck.

Materials

Github: https://github.com/push-based/perf-case-study

Stackblitz: https://stackblitz.com/~/github.com/push-based/perf-case-study/tree/angular-workspace

Demo