|
| 1 | +--- |
| 2 | +title: Fighting Framework Jank (What's Not in the Docs) |
| 3 | +description: Is your React app feeling slow? Stop blaming the framework and use your browsers APIs like template and requestIdleCallback to fix that UI jank. |
| 4 | +pubDate: Tue Nov 11 2025 21:00:00 GMT-0800 (Pacific Standard Time) |
| 5 | +hero: /blog/fighting-framework-jank-whats-not-in-the-docs/hero.webp |
| 6 | +--- |
| 7 | + |
| 8 | +I’ve been there. We’ve *all* been there. You've just shipped a new dashboard. It’s got charts, it’s got tables, it’s got pizazz ✨. And on your fancy, company issued, 16" MacBook Pro, it flies. Buttery smooth But then the first bug report comes in: |
| 9 | + |
| 10 | +> Dashboard is laggy. |
| 11 | +
|
| 12 | +Or maybe you see a "it feels slow," or my personal favorite, "the page is janky." |
| 13 | + |
| 14 | +You open it on a different machine, like your cell phone, and your heart sinks... Those smooth animations are stuttering. The clicks feel... off. And then creeps in that moment of dread, "Is React (or Vue, or Angular) just... slow?" |
| 15 | + |
| 16 | +After going through an existential crisis (doubting my years as a software developer and realizing that my imposter syndrome is very justified) I then decided to blame the framework or some library I was using. But after profiling the *very* janky dashboard I realized the problem wasn't the framework at all. |
| 17 | + |
| 18 | +The problem was me. I was so focused on the "framework way" of doing things that I was ignoring the most powerful performance tool I had: the browser itself. |
| 19 | + |
| 20 | +## The "Framework-Pure" Problem |
| 21 | + |
| 22 | +Let's look at a simplified version of my janky component. It had two main jobs: |
| 23 | + |
| 24 | +1. Render a massive, complex, but totally static SVG icon. |
| 25 | +2. Fire off an analytics event as soon as it rendered to track that it was visible. |
| 26 | + |
| 27 | +The "pure React" way to write this looked something like this: |
| 28 | + |
| 29 | +```tsx |
| 30 | +import React, { useEffect } from 'react'; |
| 31 | + |
| 32 | +// Imagine this component returns a <svg> with hundreds of <path> elements |
| 33 | +import { MyHugeStaticChartIcon } from './MyHugeStaticChartIcon'; |
| 34 | +import { sendAnalyticsEvent } from './analytics'; |
| 35 | + |
| 36 | +function JankyWidget() { |
| 37 | + useEffect(() => { |
| 38 | + // Fire this off as soon as we mount |
| 39 | + sendAnalyticsEvent('widget_visible', { detail: '...' }); |
| 40 | + }, []); |
| 41 | + |
| 42 | + return ( |
| 43 | + <div className="widget"> |
| 44 | + <h3>My Janky Widget</h3> |
| 45 | + <MyHugeStaticChartIcon /> |
| 46 | + </div> |
| 47 | + ); |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +This code *looks* right, but it's a performance nightmare. Here's why: |
| 52 | + |
| 53 | +1. **Hydration Cost:** React has to create a Virtual DOM node for every single one of those hundreds of `<path>` elements inside the SVG. That’s a ton of JavaScript objects to create and memory to allocate for something that *will never change*. |
| 54 | +2. **Main Thread Blockage:** The `useEffect` fires right after mount. That `sendAnalyticsEvent` function, even if it's quick, is still work that's happening on the main thread. It's competing for resources with the browser, which is still trying to paint the screen and respond to the user's scroll. |
| 55 | + |
| 56 | +This combination is what creates the "jank." The main thread is just too busy. |
| 57 | + |
| 58 | +<iframe |
| 59 | + src="https://stackblitz.com/edit/vitejs-vite-segdqqbc?embed=1&ctl=1&hidedevtools=1file=src%2Fpages%2FJankyWidget.tsx&initialpath=janky" |
| 60 | + style="width: 100%; aspect-ratio: 16 / 9; border: 0;" |
| 61 | + loading="lazy" |
| 62 | + title="Janky Example" |
| 63 | + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" |
| 64 | + allowfullscreen> |
| 65 | +</iframe> |
| 66 | + |
| 67 | +You can play with the janky version above! |
| 68 | + |
| 69 | +## The "One Weird Trick": Offload It to the Browser |
| 70 | + |
| 71 | +After hours of profiling, the "Aha!" moment hit me. The fix isn't a new library. It's to **stop** asking the framework to do things the browser is already amazing at. |
| 72 | + |
| 73 | +This "trick" has two parts: |
| 74 | + |
| 75 | +1. Offload **parsing** with the `<template>` tag. |
| 76 | +2. Offload **execution** with `requestIdleCallback`. |
| 77 | + |
| 78 | +## Part 1: The `<template>` Tag for Heavy Lifting |
| 79 | + |
| 80 | +First, that massive SVG. It's static. So why are we making JavaScript build it? |
| 81 | + |
| 82 | +The `<template>` tag is a native HTML element that is completely inert. The browser parses its content, but it doesn't render it, run scripts in it, or download images. It's just a chunk of DOM waiting to be used. |
| 83 | + |
| 84 | +**Step 1:** Put your static HTML into your `index.html`. |
| 85 | + |
| 86 | +```html |
| 87 | +<template id="my-chart-icon-template"> |
| 88 | + <svg width="100" height="100" viewBox="0 0 100 100"> |
| 89 | + <g> |
| 90 | + <path d="...a-very-complex-path..." /> |
| 91 | + <path d="...another-complex-path..." /> |
| 92 | + </g> |
| 93 | + </svg> |
| 94 | +</template> |
| 95 | +``` |
| 96 | + |
| 97 | +**Step 2:** Tweak your component to clone this content. |
| 98 | + |
| 99 | +```tsx |
| 100 | +import React, { useRef, useEffect } from 'react'; |
| 101 | +// ... |
| 102 | + |
| 103 | +function FastWidget() { |
| 104 | + const chartContainerRef = useRef(null); |
| 105 | + useEffect(() => { |
| 106 | + // 1. Find the template |
| 107 | + const template = document.getElementById('my-chart-icon-template'); |
| 108 | + // 2. Clone its content (this is super fast) |
| 109 | + const content = template.content.cloneNode(true); |
| 110 | + // 3. Stamp it into our component |
| 111 | + if (chartContainerRef.current) { |
| 112 | + chartContainerRef.current.appendChild(content); |
| 113 | + } |
| 114 | + // ... analytics call will go here ... |
| 115 | + }, []); |
| 116 | + |
| 117 | + return ( |
| 118 | + <div className="widget"> |
| 119 | + <h3>My Fast Widget</h3> |
| 120 | + {/* This is now just an empty container */} |
| 121 | + <div ref={chartContainerRef} /> |
| 122 | + </div> |
| 123 | + ); |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +Boom. We just saved React from having to manage hundreds of virtual DOM nodes. We offloaded all that parsing work to the browser, which it does much more efficiently. |
| 128 | + |
| 129 | +## Part 2: `requestIdleCallback` for the "Nice-to-Haves" |
| 130 | + |
| 131 | +Okay, the component renders faster, but that analytics call is still blocking the main thread in `useEffect`. This is where the second part of our "trick" comes in. |
| 132 | + |
| 133 | +`requestIdleCallback` is a browser API that's like saying, "Hey browser, I know you're busy. When you get a free second and you're not busy with user input or animations, could you please run this function for me?" |
| 134 | + |
| 135 | +It's *perfect* for non-critical tasks like analytics. |
| 136 | + |
| 137 | +Let's add it to our `useEffect`: |
| 138 | + |
| 139 | +```tsx |
| 140 | +// ... inside our FastWidget component ... |
| 141 | + useEffect(() => { |
| 142 | + // --- Template code from above --- |
| 143 | + const template = document.getElementById('my-chart-icon-template'); |
| 144 | + const content = template.content.cloneNode(true); |
| 145 | + if (chartContainerRef.current) { |
| 146 | + chartContainerRef.current.appendChild(content); |
| 147 | + } |
| 148 | + // --- Our new, non-blocking analytics call --- |
| 149 | + if ('requestIdleCallback' in window) { |
| 150 | + window.requestIdleCallback(() => { |
| 151 | + sendAnalyticsEvent('widget_visible', { detail: '...' }); |
| 152 | + }); |
| 153 | + } else { |
| 154 | + // Fallback for older browsers |
| 155 | + setTimeout(() => { |
| 156 | + sendAnalyticsEvent('widget_visible', { detail: '...' }); |
| 157 | + }, 0); |
| 158 | + } |
| 159 | + }, []); |
| 160 | +``` |
| 161 | + |
| 162 | +## The Payoff |
| 163 | + |
| 164 | +And just like that, the jank is gone. |
| 165 | + |
| 166 | +Our component now renders instantly. The state update (if we had one) happens immediately. The heavy-lifting of parsing the SVG is handled by the browser. And the non-critical analytics call waits politely for its turn when the main thread is free. |
| 167 | + |
| 168 | +I love this kind of solution! It's not about "React vs. Vanilla JS." It's about remembering that your framework is a guest in the browser's house. By respecting the browser and using the native tools it provides, you can make your framework based apps feel infinitely faster. |
| 169 | + |
| 170 | +So next time you're facing down some "jank," don't just reach for a new library. Ask yourself, "Can I just offload this to the browser?" |
| 171 | + |
| 172 | +<iframe |
| 173 | + src="https://stackblitz.com/edit/vitejs-vite-segdqqbc?embed=1&ctl=1&hidedevtools=1file=src%2Fpages%2FFastWidget.tsx&initialpath=fast" |
| 174 | + style="width: 100%; aspect-ratio: 16 / 9; border: 0;" |
| 175 | + loading="lazy" |
| 176 | + title="Fast Example" |
| 177 | + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" |
| 178 | + allowfullscreen> |
| 179 | +</iframe> |
0 commit comments