JioStar's webLR platform runs on low-end smart TVs and set-top boxes — devices with constrained CPUs running older browser versions (some as old as Chrome 68 on 2020 LG TVs). Unlike desktop Chrome, these devices can't lean on modern browser optimisations. Every millisecond of main-thread work is visible to the user.
Our field data showed that 75% of users experienced an INP of 675ms — well into Google's "poor" range (above 500ms). We needed to understand what was actually causing it before we could fix anything.
We instrumented the app using the onINP method from the web-vitals library's attribution build. The first thing we hit: all INP events were pointing to body. This was because of a third-party spatial navigation library we were using — it intercepted focus events in a way that made the source element opaque.
We worked around this by logging the focusKey attribute from the spatial navigation library, which gave us the element identity we needed to trace interactions back to the right components.
Once we had proper attribution, three interaction patterns emerged as the worst offenders:
The biggest culprit was Swipers.js, the third-party carousel we were using for horizontal tray navigation. On every horizontal key press, Swipers.js was reading the dimensions of each card in the tray — triggering layout thrashing across the entire carousel.
With 10 trays of 7 cards each, the spatial navigation library was also tracking 70 focusable elements, and a layout query on each interaction meant a large reflow on every keypress.
We replaced Swipers.js with an in-house carousel library built around composited animations. Key difference: it reads each card's dimensions once per tray (at mount), not on every interaction. The improvement in long tasks was significant — the before/after trace comparison showed the in-house version generating far fewer blocking tasks for the same interaction.
Side benefit: removing Swipers.js cut 35 KB of compressed JavaScript from the bundle.
The home page was loading all 10 tray instances on page initialisation, even the ones below the fold. Each tray set up its own DOM structure and registered focusable elements with the spatial navigation library.
We switched to lazy loading trays as they scroll into view, and applied the setTimeout() yielding strategy to split rendering work across multiple tasks. This change alone produced a 32% INP reduction.
During the splash screen, we were processing too much script, which blocked the main thread from responding to early interactions. We built an in-house asset preloader that predicts the user's likely navigation path — login → profile selection → home page → details page — and starts loading those assets in the background.
This freed the main thread for interactions while also reducing memory pressure on low-tier devices (which was a separate constraint given some set-top boxes cap Chrome at 350 MB).
One pattern we kept seeing in traces: expensive work that was no longer needed. For example, when a user focuses on a card, we start fetching the trailer. If they move to the next card before the fetch completes, that work should stop — but it wasn't.
We built a task generator utility that allows tasks to be cancelled mid-execution. When the user navigates away from a card before a fetch or rendering task finishes, the pending task gets cancelled rather than continuing to consume the main thread.
| Metric | Before | After |
|---|---|---|
| INP (p75) | 675ms | 272ms |
| Tray interaction latency | ~400ms | ~100ms |
| Homepage render (Tizen) | baseline | −31% |
| Homepage render (WebOS) | baseline | −25.2% |
| Weekly card views per user | 111 | 226 |
The weekly card views doubling was the number that mattered most to the product team. It's a direct signal that when the app feels responsive, users actually browse more.
This work was published as a web.dev case study.