Advanced front-end performance tips
Techniques beyond the basics that make speed feel like a design feature, not just a metric.
Front-end performance isn’t only about shaving off milliseconds. It’s about interfaces that feel instant. People expect speed as a baseline, not a bonus, so it has to be treated as a design principle rather than an afterthought.
This post goes past the basics: scheduling work around the user, loading only what the screen needs, hydrating less and keeping layout work out of the way. It applies whether you’re building a busy landing page or a complex web app.
Schedule work around the user
Not everything has to run the moment the page loads. Defer non-critical work until the browser is idle with requestIdleCallback (analytics, prefetching, set-up nobody is waiting for), and tie visual updates to the rendering cycle with requestAnimationFrame.
const whenIdle = window.requestIdleCallback ?? ((callback) => setTimeout(callback, 1));
whenIdle(() => {
// load non-essential scripts, warm caches
});
requestAnimationFrame(() => {
// update the UI right before the next paint
});requestIdleCallback isn’t available in every browser, hence the setTimeout fallback. Either way, the main thread stays free for what matters most: responding to input.
Load what the screen needs
Instead of loading every image and component up front, load them as they approach the viewport. The Intersection Observer API tells you when that happens, without listening to scroll events.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// load the image or component
observer.unobserve(entry.target);
}
}, { rootMargin: '200px' });
document.querySelectorAll('[data-lazy]').forEach((el) => observer.observe(el));For images, the browser does this for you with loading="lazy". Just never put it on the image at the top of the page. That one is usually the Largest Contentful Paint: it should load straight away, with a hint that it matters most.
<!-- the hero: first in line -->
<img src="hero.jpg" alt="…" width="1200" height="600" fetchpriority="high">
<!-- further down: only when it gets close -->
<img src="chart.png" alt="…" width="800" height="400" loading="lazy">The width and height let the browser reserve the space before the file arrives, so nothing jumps when it does.
Use resource hints on purpose
Resource hints are powerful, but only when they’re used deliberately:
preconnectopens the connection to a critical third-party origin early, such as a CDN or an API.dns-prefetchonly resolves the domain name: a cheaper hint for origins that matter less.preloadfetches something the current page needs but would discover late, like a font referenced from CSS.prefetchfetches, at low priority, something the next page will probably need.
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin>Every hint competes for the same bandwidth. Preload everything and nothing is a priority any more, so keep the list short and check it in the network panel.
Hydrate less
With React, Next.js or Astro, the page doesn’t have to become interactive all at once. Partial hydration makes only the interactive parts interactive, which cuts the JavaScript the browser has to download and run before the page responds.
- Astro and Qwik are built around it: static content stays static, and only the interactive “islands” ship JavaScript. I wrote about doing the same in Next.js.
- In React,
React.lazy()splits a component into its own chunk, andSuspenseboundaries let React hydrate the page in pieces, starting with the part the user interacts with.
This pays off most on landing pages, blogs and marketing sites, where most of the content never needs JavaScript at all.
Avoid layout thrashing
Layout thrashing happens when code alternates between reading layout (offsetWidth, getBoundingClientRect()) and changing it. Each read that follows a write forces the browser to recalculate the layout on the spot, and in a loop that means once per iteration.
const cards = [...document.querySelectorAll('.card')];
// Bad: every read follows a write, so each one forces a layout
for (const card of cards) {
card.style.height = `${card.offsetWidth / 2}px`;
}
// Good: all the reads, then all the writes: one layout
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => {
card.style.height = `${widths[i] / 2}px`;
});Keep layout reads out of scroll handlers and animation loops, or cache the values. I wrote a longer guide, with a React example, in Fixing layout thrashing in React.
Move heavy work off the main thread
If your app does any heavy lifting (parsing data, processing images, complex calculations), move it to a Web Worker. It runs on its own thread, so the page keeps responding while it works.
const worker = new Worker('/worker.js');
worker.postMessage(data);
worker.onmessage = (event) => {
// use the result: the UI was never blocked
};For large binary data, pass the ArrayBuffer as a transferable object: it moves to the worker instead of being copied.
Reflow and repaint: the hidden cost of visual updates
Reflow
Reflow, also called layout, is the browser working out the position and size of every element. Any change to geometry (width, height, padding, font size) makes it recalculate the affected elements and their children, and one small change can cascade through a large part of the page.
Typical triggers: changing display, position or font-size; adding or removing DOM nodes; and reading layout properties such as offsetHeight or getBoundingClientRect() after something has changed.
Repaint
Repaint happens when something changes how an element looks but not where it sits: its colour, its visibility, a shadow. There’s no geometry to recalculate, but the pixels still have to be drawn again. It’s cheaper than reflow, and still noticeable on complex screens and during animations.
Keeping both down
- Batch DOM reads and writes.
- Toggle classes instead of setting inline styles one by one.
- Animate
transformandopacity: the browser can usually hand them to the compositor and skip both layout and paint. - Cache layout values instead of reading them inside loops and animations.
- Virtualise long lists: render only the rows on screen.
Performance is a design principle
Front-end performance isn’t just a technical concern; it’s part of the experience. Every millisecond saved is a moment of trust earned. Scheduling work around the user, loading only what the screen needs and knowing what reflow and repaint cost are how interfaces end up feeling instant.
