Reduce JavaScript bundle size and image weight so pages load fast on real phones.
Why it matters
On a mid-range phone, every kilobyte of JavaScript costs twice: once to download and again to parse and execute on a slow CPU. Images are usually the heaviest bytes on a page and often the LCP element. Shipping less code and smaller images is the most reliable performance work you can do, and it is a routine mid-level task.
Measure the bundle first
Build your app for production and look at what you ship:
npm run build # Vite prints each output chunk with its size and gzip size
For a visual map, add a bundle analyser such as rollup-plugin-visualizer (Vite) or @next/bundle-analyzer (Next.js). You are looking for large libraries used on one page, duplicated dependencies, and things you did not expect at all (a whole icon set, every locale of a date library).
Code splitting
By default a bundler can put your whole app in one JavaScript file. Code splitting breaks it into chunks that load when needed. The tool is the dynamic import(), which returns a promise and tells the bundler to create a separate chunk.
Split by route. Users on the home page should not download the admin dashboard:
import { lazy, Suspense } from "react";
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
Frameworks such as Next.js and Remix split by route automatically.
Split heavy, rarely used components. A markdown editor, chart or map that appears after a click can load on demand:
const Chart = lazy(() => import("./Chart"));
// render <Chart /> only when the user opens the "Stats" tab
Load libraries at the moment of use:
button.addEventListener("click", async () => {
const { default: confetti } = await import("canvas-confetti");
confetti();
});
Shrinking what you import
- Import only what you use:
import debounce from "lodash/debounce"instead of the whole library, or use a small native alternative. - Prefer libraries that support tree shaking (ES modules), and check package size before adding one (bundlephobia.com shows it).
- Replace heavy date libraries with
Intl.DateTimeFormat, and many utility functions with built-ins. - Do not split everything: each chunk is a request. Split at routes and at big, optional features.
Images
Images often outweigh all the JavaScript on a page. Four habits fix most of it.
1. Right format. Use AVIF or WebP for photos (often 30–50 percent smaller than JPEG at similar quality), SVG for icons and logos, and PNG only when you need lossless transparency. The free web app Squoosh (squoosh.app) lets you compare formats and quality side by side.
2. Right size. Do not send a 4000-pixel photo to a 400-pixel slot. Provide several widths and let the browser choose:
<img
src="/img/course-800.webp"
srcset="/img/course-400.webp 400w, /img/course-800.webp 800w, /img/course-1600.webp 1600w"
sizes="(min-width: 960px) 33vw, 100vw"
width="800" height="450"
alt="A learner pair-programming at a laptop">
sizes tells the browser how wide the image will be displayed, so it can pick the smallest file that still looks sharp on that screen.
3. Right priority. Add loading="lazy" to images below the fold, so they load only when the user scrolls near them. Do the opposite for the hero/LCP image: no lazy loading, plus fetchpriority="high".
4. Reserve space. Always set width and height (or CSS aspect-ratio) so lazy images do not cause layout shift.
Framework image components (for example next/image) and image CDNs automate resizing and format negotiation, but you still choose sizes and priority.
Worked example
A course listing page ships a 610 kB JavaScript bundle and 3.2 MB of images. The analyser shows a 180 kB charting library used only on the "Progress" tab and a full icon pack. The images are 2400-pixel JPEG thumbnails displayed at 320 pixels.
- Lazy-load the progress tab's chart component: the initial bundle drops by about 180 kB.
- Import individual icons: another 60 kB gone.
- Export thumbnails at 320, 640 and 960 pixels in WebP with
srcset, addloading="lazy"to every card image except the first row, and set width and height.
Re-measure after each change. A result like "JS 610 kB to 370 kB, images 3.2 MB to 420 kB, LCP 3.9 s to 2.1 s" is a strong pull-request description.
Common mistakes
- Lazy-loading the hero image.
- Splitting tiny components, which adds requests and loading flashes without saving bytes.
- A
Suspensefallback with a different size from the content, which causes layout shift. - Forgetting
sizes, so the browser assumes the image is full-width and downloads the largest file. - Optimising without numbers. Record sizes before and after.
Quick recap
- Measure the production bundle, then split by route and by heavy optional features with
import()andReact.lazy. - Import less: specific modules, smaller libraries, built-ins.
- Images: modern formats,
srcsetplussizes, lazy below the fold, high priority for the hero, and always reserve space.
Try it
In the Do tab you build a small Vite app, split a heavy route, optimise an image, and compare the numbers.