INP optimization requires keeping the main thread free. Learn how to batch DOM updates using document fragments and requestAnimationFrame to prevent long tasks.
When a user clicks a button and the UI feels like it's stuck in molasses, you're looking at a classic main thread bottleneck. I spent a week recently debugging a dashboard that would freeze for nearly 300ms every time a user filtered a list of 500 items. It wasn't the logic that killed the performance; it was the unoptimized DOM thrashing happening immediately after the interaction.
Interaction to Next Paint (INP) measures the time from a user's interaction to the next frame being rendered. If your code triggers a massive DOM update synchronously, the browser has to calculate styles, layout, and paint before it can acknowledge the interaction. That's a "Long Task."
If you haven't yet explored how React reconciliation explained: How to optimize your DOM updates works, it's worth noting that even modern frameworks can struggle if you bypass their batching mechanisms with manual, imperative DOM manipulation. When you manipulate the DOM directly in a loop, you force the browser to recalculate styles repeatedly.
My first attempt at fixing the dashboard was simple: I tried to wrap the updates in a setTimeout to defer them. That didn't work—it just pushed the freeze to the next task loop. I needed to minimize the "Layout" and "Paint" cycles.
The secret is DocumentFragment. Instead of appending elements to the live document.body one by one, you append them to a lightweight, off-DOM container.
JAVASCRIPTconst fragment = document.createDocumentFragment(); data.forEach(item => { const div = document.createElement(CE9178">'div'); div.textContent = item.name; fragment.appendChild(div); // No layout thrashing here }); // Single reflow happens here document.getElementById(CE9178">'container').appendChild(fragment);
By batching these nodes into a fragment, the browser performs a single reflow/repaint cycle when you finally attach the fragment to the live DOM. It's significantly faster than 500 individual insertions.
Even with document fragments, if the batch is large enough, it can still cause a frame drop. To truly master INP optimization, you need to coordinate with the browser's refresh rate.
I started using requestAnimationFrame (rAF) to schedule the insertion precisely when the browser is ready to paint. This prevents the update from interrupting the input event handler itself.
Flow diagram: User Interaction → Task: Prepare Data; Task: Prepare Data → Create DocumentFragment; Create DocumentFragment → requestAnimationFrame; requestAnimationFrame → Attach Fragment to DOM; Attach Fragment to DOM → Paint Next Frame
By decoupling the data preparation from the rendering, you keep the main thread available to handle subsequent user inputs. If you find your data processing is still heavy, remember that Web Workers for JSON parsing: Stop blocking the main thread is always an option to move that work off the main thread entirely.
| Strategy | Performance Impact | Complexity |
|---|---|---|
| Direct Append | High (Reflows on every node) | Low |
| DocumentFragment | Low (One reflow) | Low |
| rAF + Fragment | Minimal (Frame-aligned) | Moderate |
| Virtual DOM | Optimized (Diffing) | High |
I’ve learned the hard way that you can over-engineer this. If your list has 20 items, don't bother with fragments; the overhead of managing the code isn't worth the micro-gains.
Always measure before you optimize. Use the Chrome DevTools "Performance" tab to look for those red triangles in the task timeline. If you see "Long Tasks" exceeding 50ms, that's your cue to start batching. If you're using React, check out React state batching: How React groups updates for performance to see if you can leverage the framework's built-in capabilities before resorting to manual DOM orchestration.
Does this help with CLS? Yes, but indirectly. By batching your updates, you prevent the browser from rendering "partial" states, which often cause elements to jump around while the rest of the list loads.
Can I use this with React?
Not directly. React manages the DOM for you. If you're fighting React's rendering performance, you should look at memoization or windowing (virtualization) libraries rather than manual appendChild calls.
Is requestAnimationFrame enough?
Usually, but if your task is truly gargantuan, you might need to break the loop itself into chunks using scheduler.postTask or a series of setTimeout(fn, 0) calls to yield back to the main thread between chunks.
I'm still experimenting with the new Scheduler API. It feels like a more robust way to handle these priority tasks, but the browser support is still evolving. For now, sticking to requestAnimationFrame for DOM-bound work remains the most reliable way to keep my interfaces feeling snappy.
Learn how to use the Scheduler API to improve Interaction to Next Paint (INP) by mastering task prioritization and keeping your main thread responsive.
Read moreMaster INP optimization by leveraging the `isInputPending` API and task decomposition. Learn how to keep your UI responsive and pass Core Web Vitals.