When developing web apps on our laptops, modern hardware hides a lot of performance mistakes. We can run heavy React state, extra re-renders, and animations without any lag.
Smart TVs offer no such buffer — LG webOS and Samsung Tizen run on weak single-core chips, tight VRAM, and older Chromium/WebKit, with remotes firing 15–30 keypresses a second, turning that same code into dropped frames and sluggish TVs.
This blog covers the bottlenecks we hit and the fixes that got us to 60fps.
Hardware Realities: Why TV Runtimes Struggle
Running an emulator in Chrome DevTools won’t show you real TV bottlenecks. The issues only appear on physical hardware:
| Constraint | Real-World Impact on TV |
|---|---|
| Old Browser Engines (e.g. Chromium 53) | Modern JavaScript syntax can cause syntax errors during boot. The JavaScript engine also lacks newer V8 optimizations. |
| Limited Graphics Memory (VRAM) | Every element promoted to a GPU composite layer consumes video memory. Promoting too many elements causes memory thrashing, making things slower than regular painting. |
| Weak Single-Core CPU | Heavy virtual DOM diffing, large component trees, and layout recalculations quickly max out the main thread. |
| Remote Repeat Rates (15–30 events/sec) | Holding down an arrow key sends keydown events faster than a framework can reconcile state, causing input lag and UI freezes. |
On a standard 60Hz TV screen, each frame has a strict budget of roughly 16.6ms. A single layout recalculation during navigation easily blows past that budget and causes visible stutter.
1. Improving Boot Time and Splash Screen Display
On low-end TV hardware, cold boot times can easily stretch to 8–10 seconds if the application is not optimized. Most of that delay comes from the weak CPU downloading and parsing JavaScript.
Keep the Splash Screen in Pure HTML
If your loading spinner or splash screen renders inside React or Vue, users stare at a blank screen for 3–4 seconds while the browser downloads, parses, and mounts the JavaScript bundle. Instead, put the splash screen markup and basic CSS directly inside index.html. The browser engine renders the splash immediately on frame one, giving users instant visual feedback while your framework boots quietly in the background.
Fetch Critical APIs Early in HTML Scripts
Don’t wait for React components to mount and run useEffect to start fetching your initial app configuration or user profile. Place a small inline <script> in the HTML header to start those network calls right away. The network request runs in parallel while the browser is busy parsing your JavaScript bundles, eliminating wasted wait time.
Load Only the Chunks Needed for the First Screen
Code splitting is useful, but aggressively preloading too many chunks immediately after launch can backfire. In testing, preloading secondary screens caused heavy CPU contention — the TV tried to parse background chunks while painting the home screen, freezing the UI. We solved this by only loading the strict chunks needed for the splash and landing row. Secondary chunks (settings, search, player, and analytics SDKs) are only fetched after the home screen has completely rendered and the CPU is idle.
2. Fixing Scroll Lag: Moving the Hot Path to Vanilla DOM
Our home screen consists of vertical shelves with horizontal rails of thumbnail cards. When users held down the remote’s arrow keys to scroll, the UI suffered from noticeable lag and stutter.
Why Virtualization Alone Didn’t Fix the Problem
Our initial fix: DOM virtualization, keeping only visible rows and cards in the DOM. This reduced DOM nodes, but scrolling stayed janky — the problem was framework state. Every virtual list update forced a data state update, reflected in the DOM. That forced React to re-render components and run virtual DOM reconciliation on every key press. When keys fire 20 times a second, the TV’s single-core CPU can’t keep up with reconciliation within the 16ms frame budget.
The Fix: Moving the Hot Navigation Path to Vanilla JavaScript
To fix this, we took the scrolling rail container completely out of React state and managed it using pure vanilla JavaScript:
- No More Reconciliation: Instead of re-rendering via React on every keypress, we maintain a small pool of DOM elements directly. When the user scrolls, elements are shifted and recycled using simple DOM updates.
- Hardware-Accelerated Movement: We move rows and cards entirely with transform: translate3d(…). This hands the positioning work over to the GPU compositor, keeping the CPU free.
- Single Focus Overlay: Instead of adding and removing CSS focus classes on individual cards (which triggers style recalculations across dozens of elements), we created a single focus border box that slides smoothly over cards using translate3d.
- Cache Layout Measurements: Methods like getBoundingClientRect() or offsetWidth force synchronous layout recalculations. We measure card dimensions once during initialization, store those numbers in memory, and calculate all focus positions with simple math.
Taking React out of the hot scrolling loop removed the stutter completely and kept navigation locked at 60fps.
3. Offloading Image Decoding with decoding=”async”
A typical catalog view shows 30 to 50 posters at once. Even after optimizing our DOM, we noticed micro-stutters every time a new row scrolled into view.
The culprit was image decoding. When the virtual list created new thumbnail elements, the browser decoded the JPEG/PNG image data on the main UI thread. On low-end TV processors, this image decoding temporarily blocked the main thread, causing key events to stutter.
The Fix
We added the decoding=”async” attribute to all thumbnail image elements:
<img src="poster.jpg" decoding="async" alt="Movie Title" />
This simple change made a clear difference. The browser decoded image data in the background, leaving the main thread completely free to handle remote key events and smooth scroll animations. Also, never download a 1080p image to show in a 300×170 card. Decoding and scaling down oversized images burns both CPU time and memory.
4. Finding and Fixing Repaints with DevTools Paint Flashing
One of the most useful diagnostic tools for TV development is Paint Flashing in Chrome DevTools (under More Tools > Rendering). When enabled, the browser highlights every area being repainted on the CPU with a green box.
Getting Rid of the Green Flashes
When we first turned on Paint Flashing during scroll testing, almost the entire screen flashed bright green on every arrow key press. That meant the TV was repainting every shelf and thumbnail card on the CPU on every single frame.
We eliminated these repaints using two specific CSS techniques:
- Promote the moving track to a GPU layer: We added will-change: transform to the sliding container. This pushed the moving track to its own GPU composite layer, meaning the GPU simply moved the existing texture without repainting the pixels.
- Use CSS containment on cards: You should never put will-change on every single thumbnail card — that exhausts VRAM and actually slows the TV down. Instead, we applied contain: layout style paint; to individual cards. This tells the browser that changes inside a card (like an image loading) won’t affect anything outside it.
/* Promotes only the moving container to a GPU composite layer */
.rail-container {
will-change: transform;
transform: translate3d(0, 0, 0);
transition: transform 220ms cubic-bezier(0.2, 0.5, 0.7, 1);
}
/* Isolates card paint boundaries without using extra GPU layers */
.rail-card {
contain: layout style paint;
}
Once we applied these two rules, the green flashes stopped completely during navigation, confirming that the GPU was handling the movement smoothly.
5. Keeping the Main Thread Free During Video Playback
During video playback, any UI freeze is immediately noticeable to the viewer.
Update Seek Bars and Clocks Directly in the DOM
A seek bar that updates playback progress every 250ms should never run through React or Redux state. Triggering state updates at that frequency causes constant component re-renders while the video decoder is already putting load on the system.
Instead, grab direct DOM references and update them imperatively:
// Direct DOM update avoids framework lifecycle overhead
seekBarElement.style.transform = `scaleX(${progressRatio})`;
timerElement.textContent = formattedTime;
Keep the Playback Startup Path Lean
The time between a user clicking “Play” and the first video frame appearing is critical. Defer background tasks — like fetching next-episode metadata, logging non-essential analytics, or fetching recommendations — until after the video player fires its first playing event.
Quick Summary Checklist
| Area | Best Practice |
|---|---|
| Boot Time | Put the splash screen directly in index.html; fetch initial APIs early via raw script; only load chunks needed for the first screen. |
| Navigation | Bypass framework reconciliation for scrolling rails; move containers with translate3d; slide a single focus box; cache element dimensions. |
| Images | Use decoding=”async”; request exact pixel sizes from the CDN; handle errors without component re-renders. |
| CSS & Compositing | Check repaints with DevTools Paint Flashing; add will-change: transform to sliding tracks; isolate cards with contain: layout style paint. |
| Video Player | Update seek bars directly via DOM; defer analytics and recommendations until after the video starts playing. |
Conclusion
Building responsive Smart TV applications isn’t about using complicated tricks — it comes down to respecting the hardware. When CPU and memory are limited, simple choices like moving hot navigation loops to vanilla DOM, using asynchronous image decoding, and eliminating CPU repaints with Paint Flashing makes huge difference.