Skip to main content
← Back to Blog
Next.js and React performance optimization — defer, preload, and prefetch

How to Improve Performance in Next.js and React Apps — defer, preload, and prefetch Done Right

React apps have a different performance problem than WordPress sites. There's no plugin bloat and no page builder markup — instead, the weight is JavaScript you wrote yourself: oversized bundles, third-party scripts loaded at the wrong time, and images the browser discovers too late. The good news is that Next.js ships most of the fixes built in — you just have to use them deliberately. Here's how I approach it on real projects: when to defer, what to preload, what to prefetch, and where the biggest wins actually come from.

Start With What the Browser Sees

Every performance technique in this post is really about one thing: controlling when resources load. The browser discovers resources by parsing HTML top to bottom, and by default it treats everything as equally urgent. Your job is to tell it what's critical now, what can wait, and what it should quietly fetch for later:

  • Critical now — the hero image, the fonts, the CSS for above-the-fold content. These deserve preload and high priority.
  • Can wait — analytics, chat widgets, anything below the fold. These get defer, lazy loading, or an idle-time strategy.
  • Needed soon — the next page the user will visit. This is what prefetch is for.

Before changing anything, run your app through PageSpeed Insights or a local Lighthouse audit and note the three Core Web Vitals: LCP (target under 2.5s), INP (under 200ms), and CLS (under 0.1). Keep in mind that Lighthouse measures a simulated page load — validate improvements against real-user Core Web Vitals data too (the field data in PageSpeed Insights, when available). Each technique below maps to one of those numbers.

Defer: Load Scripts at the Right Time with next/script

The classic HTML answer to render-blocking JavaScript is the defer attribute — the script downloads in parallel but only executes after the document is parsed. In Next.js you rarely write that attribute yourself, because the framework already defers its own bundles and gives you the next/script component for everything else:

import Script from "next/script";

// Analytics — load during idle time, never block the page
<Script
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
  strategy="lazyOnload"
/>

// A library the page needs once it's interactive (default)
<Script src="https://example.com/widget.js" strategy="afterInteractive" />

The three strategies, in the order you should reach for them:

  • lazyOnload — loads during browser idle time, after everything else. The right choice for analytics, ad pixels, chat widgets, and social embeds. This is the Next.js equivalent of the “delay JavaScript” feature that caching plugins sell for WordPress — and here it's free.
  • afterInteractive (default) — the closest Next.js equivalent to a deferred script: it loads without blocking rendering and executes once the page becomes interactive. Fine for scripts the page genuinely needs, but not before paint.
  • beforeInteractive — injected before any Next.js code runs. Reserve it for the rare script that must run first, like a consent manager or a bot detector. Every script here delays interactivity for the whole app.

The most common mistake I see in audits: a plain <script> tag pasted into _document or Head because that's what the third-party vendor's instructions said. That bypasses Next.js's scheduling entirely. If a script can use next/script — and almost all of them can — it should.

Preload: Tell the Browser What It Needs Right Now

preload is a strong hint to the browser: “this resource will be needed for the current page very soon, so start fetching it at high priority before you'd naturally discover it.” The classic use case is the LCP image. With server-rendered HTML the browser often discovers the hero image early on its own — but preloading still matters, because it fetches that image at the highest priority and disables lazy loading, which on image-heavy pages is often the difference between a passing and a failing LCP.

In Next.js, you usually don't write the <link rel="preload"> tag by hand. The framework exposes preloading through props:

import Image from "next/image";

// "priority" preloads the image and disables lazy loading —
// use it on exactly one thing: the LCP element
<Image
  src="/images/hero.png"
  alt="Hero"
  width={1200}
  height={600}
  priority
/>
  • Images — add priority to the LCP image (and only that one). Every other next/image stays lazy-loaded by default, which is what you want. Under the hood, priority emits a preload hint and sets fetchpriority="high" on the image. Worth knowing: Next.js 16 deprecates priority in favor of a more explicit preload prop, and you can use fetchPriority="high" when you want the priority boost without a preload tag.
  • Fonts — use next/font. It self-hosts the font files, preloads them, and sets font-display automatically, which removes both the flash of invisible text and the layout shift that hurts CLS. If you're still loading Google Fonts via a <link> tag, this is a one-file change with a visible payoff.
  • Anything else critical — a manual <link rel="preload"> in Head still works for cases the framework doesn't cover, like a video poster or a critical API response.

Preload is a budget, not a free upgrade. Every preloaded resource competes with everything else for bandwidth — if you preload five things, you've preloaded nothing.

Related hints worth knowing: preconnect opens the TCP/TLS connection to a third-party origin early (worth adding for your font CDN or API domain), and dns-prefetch is its cheaper fallback that only resolves DNS. Both go in Head and cost almost nothing.

Prefetch: Make the Next Page Feel Instant

Where preload is about this page, prefetch is about the next one — fetching at low priority, during idle time, so the resource is already cached when the user navigates.

This is the feature Next.js is quietly famous for: in production, links rendered with next/link are prefetched automatically as they enter the viewport (the exact behavior depends on the router — the App Router prefetches more selectively than the Pages Router). By the time the user clicks, the code for that route is usually already downloaded — which is why navigation in a well-built Next.js app feels nearly instant.

import Link from "next/link";

// Prefetched automatically when it enters the viewport
<Link href="/projects">Projects</Link>

// Opt out for rarely-visited pages on link-heavy screens
<Link href="/privacy-policy" prefetch={false}>Privacy Policy</Link>

Two things to keep in mind:

  • It only works in production. If you're testing navigation speed with npm run dev, you're not seeing prefetching at all. Always judge it on a production build.
  • It isn't free. Next.js throttles and deduplicates prefetches, but a page with many links can still generate substantial prefetch traffic. On dashboards or long listing pages, add prefetch={false} to links users rarely follow and let the high-traffic ones keep the default.

For data rather than routes, the same idea applies one level up: libraries like SWR and React Query can prefetch a query on hover or viewport entry, so the next screen renders with data already in the cache instead of a loading spinner.

Ship Less JavaScript in the First Place

Resource hints schedule the work; code splitting removes it. On most React apps I audit, the single biggest win isn't a hint — it's a component or library that shouldn't be in the initial bundle at all.

next/dynamic (Next.js's wrapper around React.lazy) splits a component into its own chunk that loads on demand:

import dynamic from "next/dynamic";

// The chart library (~200 KB) is split into a separate chunk
// and downloaded only when this component is needed
const SalesChart = dynamic(() => import("../components/SalesChart"));

// Client-only widgets can skip server rendering entirely
const ChatWidget = dynamic(() => import("../components/ChatWidget"), {
  ssr: false,
});

One caveat on ssr: false: reach for it only when the component depends on browser-only APIs or server rendering adds little value — like a chat widget. Disabling SSR on large chunks of a page removes them from the server-rendered HTML, which hurts both SEO and LCP.

The best candidates are easy to spot: modals and drawers that open on click, charting and map libraries, rich-text editors, anything below the fold, and anything rendered conditionally. None of that belongs in the bundle that gates first paint.

And if you're on the App Router, the biggest bundle-size lever sits one level higher: keep components as Server Components wherever possible and add "use client" only where interactivity is actually required. Every component that stays on the server is JavaScript the browser never has to download — often a bigger win than any preload or prefetch tweak.

To find what's actually heavy, run @next/bundle-analyzer once and look at the treemap. Almost every project has a surprise in there — a date library imported whole for one formatting call, an icon pack shipping a thousand unused icons, lodash imported without tree shaking. Fixing two or three of those routinely cuts more kilobytes than every resource hint combined.

And on the React side, remember that the cheapest render is the one that doesn't happen: keep component state local so typing in an input doesn't re-render the page, reach for useMemo when profiling shows a genuinely expensive computation being recomputed unnecessarily (not as a default — overusing it adds complexity for no gain), and virtualize long lists. These don't show up in Lighthouse directly, but they're what keeps INP under 200ms once real users start interacting.

A Realistic Order of Operations

If I'm handed a slow Next.js app, this is the sequence — ordered by payoff per hour of work:

  • Run Lighthouse on a production build and save the baseline.
  • Mark the LCP image for high-priority loading (using priority in Next.js 15 or preload in newer versions) and confirm every other image uses next/image with proper width/height (that's your CLS insurance).
  • Move every third-party <script> tag to next/script — analytics and widgets get lazyOnload.
  • Switch fonts to next/font; add preconnect for any remaining third-party origins.
  • Run the bundle analyzer, then next/dynamic the heavy below-the-fold components and fix oversized imports.
  • Review prefetching: keep the defaults on main navigation, opt out on link-heavy lists, and re-test.

Frequently Asked Questions

What is the difference between preload and prefetch?

Preload fetches a resource the current page needs right now, at high priority — a hero image, a critical font. Prefetch fetches a resource the user will probably need soon, at low priority during idle time — like the JavaScript for the next page. Preload is for this page's critical path; prefetch is for the next navigation.

How do I defer third-party scripts in Next.js?

Use the next/script component instead of a plain script tag. The default strategy, afterInteractive, is the closest Next.js equivalent to a deferred script — it loads without blocking rendering and executes once the page becomes interactive. For analytics, chat widgets, and other non-critical scripts, use strategy="lazyOnload" so they load during idle time and never compete with your content.

Does Next.js prefetch pages automatically?

Yes — in production builds, Next.js automatically prefetches links rendered with next/link as they enter the viewport, so navigation feels nearly instant (the App Router prefetches more selectively than the Pages Router). You can disable it per-link with prefetch={false} for rarely-visited pages, which saves bandwidth on link-heavy screens. Note that prefetching is off in development mode, so always judge it on a production build.

The Framework Does the Heavy Lifting — If You Let It

The pattern across everything above: Next.js already contains the optimization machinery — automatic code splitting, link prefetching, image lazy loading, script scheduling, font preloading. Most slow Next.js apps aren't slow because the framework failed; they're slow because something bypassed it — a raw script tag, an unoptimized image, a 300 KB library in the shared bundle.

So the discipline is less about adding clever tricks and more about staying on the paved road: one preloaded hero, deferred everything-else, prefetched navigation, and a bundle you've actually looked at. Do that, and green Core Web Vitals stop being a project and start being the default.