Scenario Debugging
Slow First Load: Bundles, Code Splitting and LCP
Bundle analysis, code splitting by route, image optimisation, font loading, preload and preconnect, and Server Components with streaming.
On this page
In one line: a slow first load usually means too much JavaScript, found too late, blocking what the user came to see. Measure the bundle (all the JavaScript files your page sends), send less, split what is left, load the main image or text first, and move work to the server.
The first load decides your LCP. LCP (Largest Contentful Paint) is a Core Web Vitals metric (Core Web Vitals are Google's three main measures of page experience): the time from the start of the page load until the largest image or block of text in the visible part of the page is drawn. A good LCP is 2.5 seconds or less at the 75th percentile (three out of four visits are at or below it). See Browser Contract, Lesson 09. It also affects SEO (how well search engines rank your page), bounce rate (the share of people who leave at once) and sales. This lesson shows you how to find the problem and make the first load smaller.
Step 1: measure what you are shipping
See the bundle
A bundle is the JavaScript file (or files) that a bundler (a build tool such as webpack, Turbopack or Vite) makes by joining your code and libraries for the browser. First, look inside it.
- Next.js 16: run
npx next experimental-analyze. It starts a local page with a treemap (a picture where each module is a box sized by its share of the total). You can filter by route and by client or server, and click a module to see which imports pulled it in. Add--outputto save the result and compare it before and after a change. (Next.js 16 no longer prints "First Load JS" sizes in thenext buildoutput, because they were not accurate with Server Components.) For Webpack builds you can use the@next/bundle-analyzerplugin instead (build withnext build --webpack):
// next.config.tsimport analyzer from "@next/bundle-analyzer";const withAnalyzer = analyzer({ enabled: process.env.ANALYZE === "true" });export default withAnalyzer({ /* your config */ });// ANALYZE=true pnpm next build --webpack- Vite/Webpack apps: use
rollup-plugin-visualizer,webpack-bundle-analyzer, orsource-map-exploreron the production source maps. - Lighthouse is a Google tool (built into Chrome DevTools) that tests a page and gives a score and advice. It shows "Reduce unused JavaScript" and "Reduce JavaScript execution time".
- The DevTools Coverage panel shows how much of each file really ran on load.
Sizes: know which number you are quoting
| Size | Meaning |
|---|---|
| Raw / parsed | What the browser must read and run. This is the real CPU cost. |
| Gzip / Brotli (transferred) | What travels over the network. This is the bandwidth cost. (Gzip and Brotli are compression formats: they make text files smaller for the trip. Brotli is usually a little smaller.) |
Say a bundle is "2 MB" but only 600 KB goes over the network. The browser must still unpack it, then parse it (read the code), compile it (turn it into machine instructions) and run all 2 MB. On a mid-range phone this is often worse than the download. Always say which number you mean.
Check the waterfall
The waterfall is a chart of every request, in time order, with a bar for each file. Use the Network tab (set to "Slow 4G" and CPU 4x slower) and a tool like WebPageTest (a free site that tests a page on real devices). Look for these things:
- When the HTML, CSS, JS, LCP image and fonts are requested.
- What blocks rendering (stops the page from drawing).
- How many requests are chained (a script loads a script that loads a font).
Step 2: the levers
A lever is one thing you can change to make the first load faster.
Lever 1: send less JavaScript
| Technique | What to do |
|---|---|
| Remove dead code and dependencies | Delete packages and components you do not use. Check for two versions of the same library. |
| Replace heavy libraries | Swap moment (large, with language data) for date-fns, dayjs or the built-in Intl. Instead of all of lodash, import single functions or use native methods. Swap big UI kits for ones that tree-shake (see next row). |
| Tree-shaking friendly imports | Tree-shaking means the bundler removes exported code that nothing imports. It works best with ES module import syntax. Write import { debounce } from "lodash-es", not import _ from "lodash". Watch out for barrel files (an index.ts that re-exports everything). They can pull in a whole package. The Next.js config option optimizePackageImports loads only the modules you use from a package, and Next.js already does this for many popular icon and utility libraries. |
| Avoid unneeded polyfills | A polyfill is extra code that adds a missing feature to old browsers. Target modern browsers with browserslist (a config that lists which browsers you support). Send legacy bundles only if your analytics show you need them. |
| Prefer server work | Format dates, render markdown, highlight code and compute data on the server. Then the code and libraries never reach the browser. |
| Avoid shipping data as JS | Do not put large JSON inside the bundle. Fetch it later, or split it into pages. |
Lever 2: code splitting
Code splitting means cutting the bundle into smaller chunks. Each page then loads only what it needs. Heavy features that few people use load only when needed. A dynamic import (import("./Chart")) is the JavaScript call that loads a module later, on demand, and returns a promise.
- Route-level splitting is automatic in Next.js. Each page is its own chunk (a separate file made by the bundler).
- Component-level splitting is for heavy or optional parts of the UI: charts, rich-text editors, maps, PDF viewers, modals, admin panels.
import dynamic from "next/dynamic";const Chart = dynamic(() => import("./Chart"), { ssr: false, // only if it needs `window`/canvas and isn't needed for first paint loading: () => <div style={{ height: 320 }} aria-busy="true" />, // reserve space to avoid layout shift});In the App Router, you can use ssr: false only inside a Client Component (a file with "use client"). Using it in a Server Component is an error. Put the dynamic() call in a small client wrapper.
// Plain React: lazy + Suspense// React.lazy loads a component only when it is first drawn; Suspense shows a fallback while it loadsconst Editor = React.lazy(() => import("./Editor"));<Suspense fallback={<EditorSkeleton />}>{showEditor && <Editor />}</Suspense>Load code when the user acts (they open the modal, so you import the editor). Or load it when the part becomes visible (use IntersectionObserver, a browser API that tells you when an element enters the screen). Do not load it when the page first opens.
Lever 3: Server Components and streaming
In the Next.js App Router, Server Components are React components that run only on the server. They send no component JavaScript to the browser, only their output. So make components server-side by default. Add "use client" only to small interactive parts (Frontend Depth, Lesson 02).
Streaming means the server sends the page in pieces instead of waiting to finish all of it. You use <Suspense> boundaries for this (a <Suspense> boundary shows a fallback until the content inside is ready). The page shell and the top of the page arrive first. Slower data follows later. Less JS also means less hydration, which is the browser work that attaches React and event handlers to server-made HTML so it becomes interactive. See Lesson 01.
Lever 4: images (the usual LCP element)
- Right format: use AVIF or WebP instead of PNG or JPEG where the browser supports them.
- Right size: use responsive
srcsetandsizes(HTML attributes that let the browser pick the best file for the screen width). Never show a 4000 px image at 400 px. - Right priority: the LCP image must be in the HTML. It must not be lazy-loaded (lazy-loading means the browser fetches an image only when it is near the screen, which is too late for the main image). Fetch it with
fetchpriority="high", an HTML attribute that tells the browser this file matters most. In Next.js, write<Image fetchPriority="high" loading="eager" />. The olderpriorityprop is deprecated since Next.js 16 (thepreloadprop replaced it; the docs adviseloading="eager"orfetchPriority="high"in most cases). Everything below the fold (the part you must scroll to see) getsloading="lazy". - Reserve space: set
widthandheight, or CSSaspect-ratio. This stops layout shift (content jumping when something loads). - Serve from a CDN (content delivery network: many servers around the world that keep copies of your files close to users) with long cache times and correct compression.
import Image from "next/image";<Image src="/hero.jpg" alt="…" width={1200} height={630} fetchPriority="high" loading="eager" sizes="100vw" />Lever 5: fonts
Web fonts can hide your text for a while (invisible text). They can also move the layout when the real font replaces the first one.
- Self-host the fonts (serve them from your own domain) and subset them (keep only the letters and weights you use).
next/fontdoes this. It also removes the extra network trip to a font CDN. - Use
font-display: swap(a CSS rule that shows text at once in a fallback font and swaps in the real font when it loads).optionalis similar, but it skips the swap if the font is late. - Use size-adjusted fallbacks (the CSS descriptors
size-adjustandascent-override). They make the fallback font about the same size as the real one, so less shift happens.next/fontdoes this for you. - Preload (tell the browser early to fetch) the one or two most important font files. Keep the number of weights and styles low.
Lever 6: CSS and render-blocking resources
A render-blocking resource stops the browser from drawing the page until it has loaded. CSS blocks rendering. Synchronous scripts (a plain <script src> with no defer or async) block parsing (the browser's reading of the HTML). See Browser Contract, Lesson 00.
- Keep CSS small. Remove unused CSS (Tailwind's purging, CSS modules). Put critical CSS (the CSS needed for the first screen) inline, if your framework supports it.
- Use
defer(download while the HTML is read, run after it is read),async(run as soon as it downloads) ortype="module"(deferred by default) for scripts. Avoid synchronous third-party tags in<head>. - Load third-party scripts (analytics, chat, A/B tools) with
next/scriptstrategies.afterInteractive(the default) loads after some hydration.lazyOnloadloads when the browser is idle. Also ask if you need each one at all.
Lever 7: network and delivery
- Compression: use Brotli (or gzip) for text files (HTML, CSS, JS, JSON, SVG) at the CDN or server.
- Caching: give hashed files (files with a content hash in the name, so the name changes when the content changes) a year-long
Cache-Control: public, max-age=31536000, immutableheader.immutabletells the browser the file will never change. See Browser Contract, Lesson 05. - CDN to cut RTT (round-trip time: the time for a request to go and come back). Use HTTP/2 or HTTP/3 (newer versions of the web protocol) so many requests can share one connection.
- Fewer origins: each new origin (the scheme, domain and port, such as
https://cdn.example.com) adds DNS (finding the server's address), TCP (opening the connection) and TLS (setting up encryption) work. Usepreconnectonly for the few that are truly critical. - Avoid redirect chains (one redirect leading to another) such as
httptohttpstowwwto/en.
Preload vs prefetch vs preconnect
These are hints you give the browser about what to load early.
| Hint | Meaning | Priority | Use for |
|---|---|---|---|
preload | "I need this for the current page. Fetch it now." | High | The LCP image, a critical font, a key script found late |
prefetch | "I might need this for a future page." | Lowest, runs when the browser is idle | The JS and data of the likely next page (Next's <Link> prefetches by itself) |
preconnect | Open DNS + TCP + TLS to an origin early | Not a download (setup only) | A critical third-party origin (API, font host) |
dns-prefetch | Look up the DNS only | Low | Less important third-party origins |
Difference in one line: preload is for this page and starts now; prefetch is for a later page and waits until the browser is idle.
Too much preload hurts. The files compete with other important files. Preload one or two things that you have checked are found late.
Putting it together
Example numbers (not a measurement):
Typical LCP timeline (bad) Optimised───────────────────────────── ─────────────────────────────HTML ........ 900 ms (no CDN, no streaming) HTML (CDN, streamed) ...... 150 msCSS ......... blocks until 1,100 ms Critical CSS inline ........ 0 ms extraJS bundle 2 MB executes until 2,400 ms ~150 KB initial JS, rest lazyLCP image discovered by JS at 2,500 ms <img fetchpriority=high> in HTML, fetched at 200 msLCP ≈ 3.8 s LCP ≈ 1.4 sScenario: the JS bundle is 2 MB. Plan how to halve it.
- Set a baseline. Write down the parsed and compressed sizes per route (from the analyzer), the Lighthouse unused-JS number, and the real-user LCP and INP. Real-user data (also called field data) comes from the browsers of your actual visitors. INP (Interaction to Next Paint) is a Core Web Vitals metric: how long the page takes to show a visual response after a click, tap or key press. A good INP is 200 ms or less.
- Analyse. Run the bundle analyzer. List the top 10 contributors. Look for the usual causes:
- a date or charting library
- a whole icon pack
- moment locales
- two versions of React or lodash
- an unused UI kit
- a polyfill bundle
- Do the quick wins first:
- Remove unused dependencies and dead code.
- Fix imports (one icon, one function,
lodash-es). Add a package tooptimizePackageImportsif it exports hundreds of modules and is not already handled. - Remove duplicate versions (
pnpm dedupe, resolutions). - Target modern browsers to drop polyfills.
- Split what is left. Use dynamic imports for heavy features: below-the-fold parts and features used only after a click (editor, charts, admin, PDF export). Check that each one now loads only when needed.
- Move work to the server. Turn parts that are not interactive into Server Components. Do formatting, highlighting and markdown on the server.
- Replace heavy libraries with lighter or native options where it is worth it.
- Check delivery: Brotli, cache headers, CDN.
- Lock it in. Set a performance budget (a size or time limit you agree not to pass) in CI (continuous integration: checks that run automatically on every code change). Tools:
size-limitor Lighthouse CI. The CI job fails when the JS goes over the limit. - Measure again: parsed and compressed sizes, route JS size, LCP, INP and Total Blocking Time (TBT: a lab metric that adds up, for every task longer than 50 ms, the time beyond the first 50 ms, between first paint and the page becoming interactive). The main thread is the one thread that runs your JavaScript and also draws the page. After release, confirm the gain in real-user data.
Diagnosing a poor LCP: a short procedure
- Find the LCP element. Use Lighthouse, or the "LCP" marker in the DevTools Performance panel (its insights sidebar also explains LCP).
- Break LCP into four parts (when the LCP element is text, the two "resource" parts are zero): TTFB (Time To First Byte: the time until the first byte of the HTML arrives), resource load delay, resource load duration and element render delay.
- Fix the biggest part:
- High TTFB: use a CDN, caching, streaming and faster data (Lesson 02).
- High load delay: make the LCP resource easy to find in the HTML. Preload it. Remove heroes added by JS or set as CSS backgrounds. Do not lazy-load it.
- High load duration: use a smaller, modern, correctly sized image. Use a CDN.
- High render delay: look for render-blocking CSS or JS, client-side rendering that waits for JS, and a busy main thread.
Common mistakes
- Quoting only the gzipped size and ignoring the cost to parse and run the code.
- Lazy-loading the hero image.
- Importing a whole library for one function, or importing through barrel files.
- Rendering content in the browser when the server could render it.
- Preloading everything.
- Loading third-party scripts synchronously or at page load.
- No performance budget, so the bundle grows back.
- Tuning on a fast machine with a warm cache (files already stored in the browser).
Interview questions
How do you diagnose a poor LCP?
First I find the LCP element. Then I split LCP into four parts: TTFB, resource load delay, load duration and element render delay. I fix the biggest part.
- For slow TTFB, I use a faster server and caching.
- For load delay, I make the LCP resource easy to find and give it priority. It goes in the HTML, with preload and
fetchpriority, and it is not lazy. - For load duration, I use smaller, modern images.
- For render delay, I remove render-blocking CSS and JS, or client-side rendering delays.
At the end I check with real-user data.
Scenario: the JS bundle is 2 MB. Plan how to halve it.
I write down the baseline sizes and metrics. Then I run a bundle analyzer. I remove unused and duplicate dependencies, and fix imports and polyfills. I split heavy or rarely used features with dynamic imports. I move work that is not interactive to Server Components. I replace heavy libraries. I make sure Brotli and caching are on, and I add a size budget in CI. At the end I measure parsed size, per-route JS and LCP/INP again, and I check real-user data after release.
Preload vs prefetch?
preload fetches a resource that the current page needs, at high priority. Examples are the LCP image and a critical font. prefetch fetches a resource that a future page may need, at the lowest priority, when the browser is idle. If you preload too much, you slow down the important files. Prefetch makes the likely next pages faster.
What is the LCP element, and how do you speed it up?
LCP is the time when the largest image or text block in the viewport (the part of the page you can see) is drawn. The LCP element is that element. It is usually a hero image (the big image at the top) or a heading. To speed it up:
- Make it easy to find early in the HTML. It should not be a CSS background or added by JS.
- Give it priority with
fetchpriority="high"and preload. In Next Image, usefetchPriorityorpreload. - Serve it small and in a modern format, from a CDN.
- Remove render-blocking files that delay drawing it.
Why does bundle size matter beyond download time?
The main thread must parse, compile and run JavaScript. This is slow on mid-range phones. It delays rendering, hydration and interactivity (INP). The compressed size affects network time. But the uncompressed size decides the CPU cost.
How do Server Components help first-load performance?
They run on the server and send no component JavaScript. So the browser downloads, parses and hydrates less code. With streaming Suspense boundaries, users see content sooner. The main thread is also freer to respond to clicks.
How do you stop a bundle from growing back?
I set a performance budget and enforce it in CI (size-limit, Lighthouse CI). size-limit is a tool that fails the build when the bundle is too big. I review new dependencies. I show the bundle size change in each pull request. After each release I watch real-user LCP and INP.
How do you handle third-party scripts?
First I ask if each one is really needed. I load them asynchronously with a late strategy (lazyOnload, or after the user acts). For heavy ones, I self-host them or isolate them (Partytown, a library that runs third-party scripts in a web worker, which is a background thread, where it makes sense). I measure their effect on long tasks (tasks that block the main thread for more than 50 ms) and INP. I remove the ones that cost more than they give.
Key takeaways
- Measure the parsed and compressed bundle size, route by route. Use an analyzer and Lighthouse. Read the waterfall.
- Send less (dead code, lighter libraries, tree-shaking, no needless polyfills). Split the rest (dynamic imports when the user acts or the part is visible). Move work to Server Components.
- Make the LCP resource load early and with high priority. Optimise images and fonts. Keep CSS and scripts from blocking.
- Use
preloadsparingly,prefetchfor next pages, andpreconnectfor critical origins. - Enforce a budget and watch real-user data.
What's next
Lesson 07: Rendering 10,000 Rows. After the page has loaded, a very common problem is showing a very large list or table without freezing the browser.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Concurrent React, Suspense and Server Components in Next.js · Senior Frontend Depth
- Next.js Interview Questions · Full-Stack Questions
- Next.js From Zero: Project Structure, Routing and Layouts · Next.js App Router
Part of the study guide
Related series