Back

Frontend Performance Tricks We Forgot About

Frontend Performance Tricks We Forgot About

The fastest way to slow down a modern frontend is to assume the framework handles performance for you. Most of the low-level techniques that defined fast websites a decade ago — explicit image dimensions, deferred third-party scripts, font-display hints, manual preconnects — still matter, but the framework now sits between you and the platform, and the abstraction leaks in ways a Lighthouse audit will happily flag. In 2019, ButterCMS wrote that “browsers will soon support lazy loading natively.” That future arrived, became baseline, and then quietly became something we stopped checking. This article walks through the frontend performance fundamentals we delegated away and the production failure modes that show up when the delegation breaks.

Key Takeaways

  • Native loading="lazy" has been supported across all major browsers since Safari 15.4 (March 2022), so any Intersection Observer wrapper written before that date is dead code adding bundle weight.
  • Google Fonts can serve fonts with font-display: swap, but custom @font-face blocks in your own CSS do not inherit that behavior — each one is a potential flash of invisible text on slow connections.
  • setTimeout(fn, 0) runs in the next task and can land inside a user interaction; requestIdleCallback waits for a genuine idle period, making it the correct primitive for non-urgent work.
  • Use defer for scripts that touch the DOM and async only for genuinely independent scripts, because async scripts execute in network-arrival order, not document order.
  • Since INP replaced FID as a Core Web Vital in March 2024, unthrottled scroll and resize handlers that block the main thread are now a ranking signal, not just a smoothness issue.

Explicit width/height on Images Still Prevents Layout Shift

In React applications where image dimensions come from API responses at runtime, the browser has no reserved space to allocate, so every image that loads after first paint is a potential layout shift — regardless of whether your framework’s image component handles static assets correctly. Cumulative Layout Shift is one of the Core Web Vitals defined by Google’s web.dev CLS documentation, and the visible failure mode is concrete: the page jumps when an image loads, and the user’s tap lands on the wrong button.

The abstraction that leaks here is the framework image component. Next.js <Image> reserves space when you pass width and height, but it does nothing for raw <img> tags in MDX content, CMS-rendered HTML, or any markup the component never touches. Modern browsers compute an implicit aspect-ratio from the width and height attributes (MDN documents this behavior), so the dimensions reserve space even when CSS overrides the rendered size.

// Before: dimensions arrive at runtime, nothing reserves space
<img src={product.imageUrl} alt={product.name} />

// After: attributes set an aspect-ratio box before the image loads
<img
  src={product.imageUrl}
  alt={product.name}
  width={product.width}
  height={product.height}
  style={{ width: '100%', height: 'auto' }}
/>

When the API omits dimensions, set an aspect-ratio on the container instead. Either way, the space exists before the bytes arrive.

font-display: swap and Preconnect for Custom Fonts

Every custom @font-face block without font-display: swap is a potential flash of invisible text — FOIT — on slow connections, where a paragraph stays blank for the full font-fetch duration. The font-display descriptor controls this directly: swap renders fallback text immediately and swaps to the custom font once it loads, producing a flash of unstyled text (FOUT) instead, as described in MDN’s font-display reference.

The leak is delegation. Google Fonts can inject font-display: swap into the CSS it serves when the stylesheet URL includes the appropriate display parameter, so teams that use the hosted stylesheet never think about it — and then write their own @font-face blocks for brand fonts that do not inherit that behavior. A self-hosted font without the descriptor ships FOIT to every visitor on a cold cache.

Self-hosting also drops the preconnect that the Google stylesheet implicitly encouraged. web.dev’s guidance on early network connections recommends preconnecting to the font origin so the DNS, TCP, and TLS handshakes finish before the font URL is discovered in CSS.

@font-face {
  font-family: "BrandSans";
  src: url("/fonts/brand-sans.woff2") format("woff2");
  font-display: swap; /* fallback text shows immediately, no FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />

Audit every @font-face block you wrote by hand. The hosted-CSS habit hides the ones that need fixing.

preconnect and dns-prefetch for Third-Party Origins

Bundlers and frameworks handle preconnect for their own CDN origins, but third-party analytics endpoints, image CDNs, and A/B testing services are invisible to the build step — their DNS lookups happen at request time unless you add <link rel="preconnect"> manually. ButterCMS described the mechanism precisely in 2019: preconnect tells the browser to complete the DNS lookup, initial connection, and TLS negotiation “as soon as possible, rather than later on when the script tag is discovered.”

The DNS-and-TLS handshake cost has not gone away; the framework just stopped reminding you about it. A Segment endpoint, a Cloudinary origin, or a third-party tag manager each requires a fresh connection setup that blocks the resource behind it. Use preconnect for origins you know you will hit early, and dns-prefetch as a lighter-weight hint for origins you might hit, since preconnect opens a full connection you pay for whether or not it is used. web.dev covers the tradeoff between the two hints.

<!-- Critical third-party origin: open the full connection now -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />

<!-- Likely-but-not-certain origin: just resolve DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />

Place these high in <head>, before the scripts and stylesheets that trigger the requests.

Native loading="lazy" Replaced Your Intersection Observer Wrapper

Native loading="lazy" has been supported across all major browsers since Safari 15.4 shipped in March 2022 — any Intersection Observer wrapper written before that date is now dead code adding bundle weight and maintenance surface. Chrome shipped it in version 77 (August 2019) and Firefox in version 75 (April 2020), per the browser compatibility table in MDN’s img element reference.

The leak here is historical, not framework-specific. Codebases accumulated useLazyImage hooks and <LazyImage> components in the years before the attribute reached baseline, and those components still ship — running an observer per image, holding refs, and re-rendering on intersection — to do what the browser now does natively and off the main thread. The same attribute works on iframes, which matters for embedded maps and video players below the fold.

// Before: a hand-rolled observer that the platform made redundant
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) setVisible(true);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}

// After: the browser handles it, off the main thread
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;

Keep the explicit dimensions — lazy-loaded images shift layout just as readily as eager ones.

defer vs async on Third-Party Scripts

The practical decision rule: use defer for any script that reads or writes the DOM, and async only for scripts that are genuinely self-contained — because async scripts execute in network-arrival order, not document order, and two async scripts with a dependency between them will race. The HTML Living Standard’s script element definition specifies that defer scripts run after parsing completes, in document order, while async scripts run as soon as they finish fetching.

The leak is social, not technical: someone pastes a vendor analytics snippet into <head> exactly as the vendor’s copy-paste instructions show it, with no attribute, and a plain <script> blocks parsing until it downloads and executes. The visible failure mode is interaction delay. When third-party scripts block interaction, replays show repeated taps on the same control — a textbook rage-click pattern.

AttributeExecution timingOrder guaranteeUse for
noneBlocks parser immediatelyDocument orderAlmost never
asyncAs soon as fetchedNetwork-arrival orderIndependent analytics
deferAfter parsing completesDocument orderAnything touching the DOM
<!-- Before: blocks the parser, delays first paint and interaction -->
<script src="https://vendor.example/analytics.js"></script>

<!-- After: independent script, never blocks the parser -->
<script src="https://vendor.example/analytics.js" async></script>

requestIdleCallback Instead of setTimeout(fn, 0)

setTimeout(fn, 0) schedules work in the next task queue slot, which can land squarely in the middle of a user interaction; requestIdleCallback waits for a genuine idle period, making it the correct primitive for analytics initialization, prefetch hydration, and telemetry batching. The distinction is documented in MDN’s requestIdleCallback reference: the callback fires during the browser’s idle periods and receives a deadline you can check before doing more work.

This is the primitive most teams never adopted — setTimeout(fn, 0) became the reflexive “do this later” idiom, and it does not actually yield to the user. Since INP replaced FID as a Core Web Vital in March 2024 (per web.dev’s INP announcement), main-thread work that lands during an interaction is no longer just a smoothness issue — it is a ranking signal. requestIdleCallback is supported in Chrome and Firefox but not in Safari, so feature-detect and fall back.

function whenIdle(fn) {
  if ("requestIdleCallback" in window) {
    requestIdleCallback(fn, { timeout: 2000 });
  } else {
    setTimeout(fn, 0); // Safari fallback
  }
}

// Defer non-urgent work off the interaction path
whenIdle(() => initAnalytics());

The timeout option guarantees the work eventually runs even if the browser never goes idle.

Debounce and Throttle on Scroll, Resize, and Input

Unthrottled scroll, resize, and input handlers that block the main thread are now a ranking signal, not just a smoothness issue — every frame they delay is a potential INP violation. The pattern broke because useEffect makes attaching a raw listener trivial: three lines, no rate limiting, and a handler that fires on every scroll frame.

Debounce runs a function after activity stops — correct for search inputs and resize-end work. Throttle caps frequency — correct for scroll position tracking that must update during the gesture. The MDN scroll event reference notes that scroll events can fire at a high rate and recommends throttling expensive handlers.

useEffect(() => {
  let ticking = false;
  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      updateScrollPosition(window.scrollY);
      ticking = false;
    });
  }
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);

The requestAnimationFrame gate throttles to one update per frame, and { passive: true } tells the browser the handler will not call preventDefault, letting it scroll without waiting on your JavaScript.

The Compounding Pattern

Every technique in this article is platform knowledge we offloaded to a framework default and stopped verifying. None of them is new — that is the point. Individually, a missing font-display or an un-deferred tag costs milliseconds; together they are the gap between an app that feels fast and one that feels heavy despite modern tooling. The next concrete action: open DevTools, audit your hand-written @font-face blocks, your third-party <script> tags, and your useEffect listeners against the rules above, and delete the Intersection Observer wrapper the browser made redundant.

FAQs

Use debounce when you only care about the final state after activity stops, such as firing a search request after the user stops typing or recalculating layout after a resize ends. Use throttle when you need updates during a continuous gesture at a capped rate, such as tracking scroll position. Debounce waits for a pause; throttle limits frequency while the event keeps firing.

Yes. The loading attribute applies to both img and iframe elements, so embedded maps, video players, and third-party widgets below the fold can defer loading natively without an Intersection Observer wrapper. Browser support tracks the image rollout closely, reaching baseline across Chrome, Firefox, and Safari in the same era. Keep explicit width and height to prevent layout shift, since lazy-loaded elements shift just as readily as eager ones.

They race and may execute in the wrong order. Async scripts run as soon as each finishes downloading, in network-arrival order rather than document order, so a script that depends on another async script can run first and fail. The fix is to use defer for both scripts, which guarantees execution after parsing completes and in document order, or to load the dependency before the dependent script in a single bundle.

setTimeout with a zero delay schedules work in the next task queue slot, which the browser may run immediately, including in the middle of a user interaction, so it does not actually yield to the user. requestIdleCallback waits for a genuine idle period and passes a deadline you can check before continuing. Since INP became a Core Web Vital in March 2024, this distinction matters because work that lands during an interaction is now a ranking signal.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay