Skip to content
Wadhah Belhassen
← All articlesWeb Performance

JavaScript Performance Optimization: Cut Bundle Size, Speed Up Interactions

JavaScript performance optimization guide — bundle splitting, tree shaking, lazy loading, web workers, and the patterns that fix slow apps without rewrites.

Wadhah Belhassen2026-11-2012 min read
JavaScript Performance Optimization: Cut Bundle Size, Speed Up Interactions

JavaScript performance optimization is where most modern web apps live or die. The framework you chose, the libraries you imported, the way you structure your code — all of it accumulates into a JavaScript footprint that either lets your app feel instant or makes it feel slow. Most apps we audit are shipping 2 to 4 times more JavaScript than they need.

This guide covers JavaScript performance end to end. Bundle splitting, tree shaking, lazy loading, dead code elimination, web workers, and the specific patterns that move both LCP and INP without rewriting the app. By the end you will know exactly which levers to pull on your own JavaScript footprint.

The work is technical. Done right, most apps can cut JavaScript shipped to clients by 40 to 70 percent in 30 to 60 days through systematic optimization.

Why JavaScript dominates modern web performance

Modern web apps run on JavaScript. The bundle has to:

  • Download from the server
  • Parse and compile in the browser
  • Execute, often blocking the main thread
  • Hydrate React/Vue/Angular components
  • Handle subsequent interactions

Each phase has its own cost. A 500 KB JavaScript bundle on a mid-tier Android phone can take 1.5 to 3 seconds just to parse and compile. That's before any user interaction.

We covered the broader Core Web Vitals impact in our Core Web Vitals deep dive guide. JavaScript is usually the biggest contributor to INP issues and a major contributor to LCP.

The JavaScript performance hierarchy

Optimisations have different leverage. Apply in this order.

Tier 1 — Don't ship what you don't need

The biggest win. Dead code, unused libraries, redundant abstractions. Removing always beats optimising.

Tier 2 — Code split what you do ship

Don't load all the code on every page. Split by route, by feature, by user state.

Tier 3 — Defer non-critical execution

Scripts that don't need to run before first paint should defer. Free LCP improvement.

Tier 4 — Optimise hot paths

For the JavaScript that does run on critical paths, optimise the actual code. Memoization, avoiding unnecessary work, web workers.

Working in this order produces the biggest wins for the least effort. Most teams reverse the order and waste weeks optimising code that should have been removed entirely.

Section 1 — Audit your bundle

You cannot optimise what you cannot see. Start with measurement.

Bundle analyzer tools

  • @next/bundle-analyzer for Next.js: shows treemap of bundle composition
  • source-map-explorer for generic webpack bundles
  • vite-bundle-analyzer for Vite projects
  • webpack-bundle-analyzer for webpack

Run monthly. The first run usually surfaces 3 to 5 surprising bundle bloat sources.

What to look for

  • Single dependency over 100 KB → audit if needed, look for alternatives
  • Multiple copies of similar libraries → consolidate
  • Polyfills for browsers you do not support → remove
  • Development-only utilities ending up in production → exclude

Chrome DevTools Coverage tab

DevTools → Cmd+Shift+P → "Show Coverage"

Records which JavaScript actually runs vs what is loaded. Scripts with under 30 percent coverage are candidates for removal or replacement.

This is the most actionable tool for finding dead JavaScript. Most apps have 50 to 80 percent unused JavaScript loaded on every page.

Section 2 — Remove dead code

The simplest performance win. Audit and delete.

Common dead code sources

Unused dependencies. Every package.json grows. Run periodic audits.

npx depcheck

Outdated polyfills. Polyfills for browsers your team no longer supports (IE11, old Safari) silently bloat bundles.

Removed features still imported. When you remove a feature, the imports often linger.

Console statements in production. Strip with build tooling.

Commented-out code. If you do not need it, remove it. Comments still ship.

Tree shaking

Modern bundlers (webpack 5+, Rollup, esbuild) strip exports that are not imported. This works only when:

  • Modules use ES modules (import/export)
  • Side effects are explicitly declared in package.json
  • You import specific functions, not entire packages
// Bad — imports the entire Lodash package
import _ from "lodash";

// Good — imports only what you need
import debounce from "lodash/debounce";

The bad import ships 70+ KB. The good import ships 2 KB.

Audit big libraries

Common offenders in 2026:

  • Moment.js (200 KB): replace with date-fns (~6 KB per function) or Day.js (~2 KB)
  • Full Lodash (70 KB): import specific functions, or replace with native methods
  • jQuery (90 KB): in a modern app, almost always replaceable with native methods
  • Axios (15 KB): native fetch() covers most use cases
  • React Router v5 (50 KB): v6 is smaller

Each replacement is a measurable bundle reduction.

Section 3 — Code splitting

Don't load all the code on every page.

Route-based splitting

For Next.js App Router, this is automatic. Each route's code only ships when that route is visited.

For other frameworks, use dynamic imports:

const HeavyPage = lazy(() => import("./HeavyPage"));

The framework's router handles the loading state.

Component-level splitting

For heavy components used on specific pages or interactions:

import dynamic from "next/dynamic";

const HeavyChart = dynamic(() => import("./HeavyChart"), {
  loading: () => <Skeleton />,
  ssr: false,
});

Common candidates:

  • Charts and data visualisation (Chart.js, D3, Recharts)
  • Rich text editors (TinyMCE, CKEditor, Quill)
  • Map components (Mapbox, Google Maps)
  • Video players (Plyr, VideoJS)
  • Modals and dialogs only used occasionally

Each is 50 to 500 KB. Deferring them off the critical path lifts initial load dramatically.

Library-level splitting

For libraries that are large but used in specific contexts, load them on demand:

async function handleExport() {
  const { exportToPdf } = await import("./pdfExporter");
  exportToPdf(data);
}

The PDF exporter library only downloads when the user clicks "Export to PDF". Initial page weight drops.

Section 4 — Defer non-critical JavaScript

Even code that needs to ship can be deferred from the critical path.

Defer attribute

<script src="analytics.js" defer></script>

defer lets the script download in parallel with HTML parsing and run after the document is parsed. Removes main-thread blocking during initial render.

Module scripts default to defer

<script type="module" src="app.js"></script>

ES module scripts behave like defer by default.

async vs defer

  • async: downloads in parallel, runs as soon as ready, can interrupt parsing
  • defer: downloads in parallel, runs after document is parsed

Defer for analytics and most scripts. Async only when execution order does not matter (rare in practice).

requestIdleCallback for non-urgent work

requestIdleCallback(() => {
  performNonUrgentWork();
});

Defers work until the browser is idle. Useful for prefetching, sending analytics, warming caches.

Section 5 — Optimise React (and similar framework) renders

Once the JavaScript ships, the next bottleneck is execution speed during user interactions. For React apps specifically:

Memoize expensive computations

const expensiveValue = useMemo(() => computeExpensiveValue(data), [data]);

useMemo recomputes only when dependencies change. Critical for expensive operations inside render.

Prevent unnecessary re-renders

const Child = React.memo(({ data }) => /* ... */);

React.memo skips re-render when props are unchanged. Combine with useCallback for callback props:

const handleClick = useCallback(() => doSomething(id), [id]);

Virtualize long lists

For lists of 100+ items, virtualisation renders only the visible items.

Libraries:

  • react-window for simple lists
  • react-virtuoso for more features
  • @tanstack/react-virtual (formerly react-virtual)

A list of 10,000 items renders 20 visible at a time. DOM stays small, scroll stays smooth.

Avoid creating objects and functions in render

// Bad — new object every render
<Child config={{ option: true }} />

// Good — stable reference
const config = { option: true };
function Component() {
  return <Child config={config} />;
}

New objects break React.memo comparison. Stable references let memoization actually work.

Section 6 — Web workers for CPU-heavy work

For genuinely expensive computation, offload to a web worker.

When workers make sense

  • Image processing
  • PDF generation
  • Heavy data transformation
  • Large JSON parsing
  • Anything that takes 200+ ms on the main thread

Workers run on a separate thread. The main thread stays responsive.

Basic worker setup

// worker.js
self.onmessage = (e) => {
  const result = expensiveComputation(e.data);
  self.postMessage(result);
};

// main.js
const worker = new Worker("/worker.js");
worker.postMessage(input);
worker.onmessage = (e) => useResult(e.data);

Comlink for ergonomic workers

import * as Comlink from "comlink";

const api = Comlink.wrap(new Worker("/worker.js"));
const result = await api.compute(input);

Comlink makes worker communication feel like async function calls. Massive ergonomic improvement.

Partytown for third-party scripts in workers

Partytown runs third-party scripts (analytics, chat, marketing tags) in a web worker. The scripts execute but never block the main thread.

Trade-offs: some scripts do not work in workers. Test before rolling out broadly.

Section 7 — Hot path optimization

For code that runs frequently (scroll, input, animation), small optimizations matter.

Debounce and throttle event handlers

import debounce from "lodash/debounce";

const handleResize = debounce(() => {
  // expensive work
}, 200);

window.addEventListener("resize", handleResize);

Without debouncing, a single resize fires the handler 30+ times. Debouncing limits to once per cooldown period.

Avoid forced layout (layout thrashing)

// Bad — forces layout on every iteration
items.forEach(item => {
  item.style.height = item.offsetWidth + "px";
});

// Good — read all, then write all
const widths = items.map(item => item.offsetWidth);
items.forEach((item, i) => {
  item.style.height = widths[i] + "px";
});

Reading layout values (offsetWidth, getBoundingClientRect) after writing forces synchronous layout. Doing it inside a loop is catastrophic.

Use requestAnimationFrame for visual updates

function animate() {
  // update DOM here
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Synchronises visual updates with the browser's render cycle. Smoother animations.

Passive event listeners

window.addEventListener("scroll", handler, { passive: true });

For scroll, touch, and wheel events that do not call preventDefault, marking as passive lets the browser skip waiting for the handler before scrolling.

Section 8 — Modern JavaScript features

ES2020+ features deliver real performance wins.

Optional chaining and nullish coalescing

const name = user?.profile?.name ?? "Guest";

Smaller and faster than equivalent if-checks. Bundlers transpile to optimal code.

Top-level await

const data = await fetch("/api/data").then(r => r.json());

In ES modules, top-level await means cleaner code without the IIFE wrapper. Smaller, more readable.

Async iterators

for await (const item of asyncIterable) {
  process(item);
}

Cleaner async loops than chained .then(). Often smaller compiled output.

A 30-day JavaScript performance optimization plan

If your app has bloated JavaScript, follow this sequence.

Days 1 to 4 — Audit. Run bundle analyzer. Run Chrome DevTools Coverage. Identify top 5 bundle bloat sources.

Days 5 to 10 — Remove dead code. Audit dependencies, remove unused. Replace large libraries with smaller alternatives. Strip console statements.

Days 11 to 16 — Code split. Identify heavy components. Convert to dynamic imports. Set up route-based splitting if not already.

Days 17 to 21 — Defer. Add defer to non-critical scripts. Move analytics off the critical path. Use lazyOnload strategies for chat and embeds.

Days 22 to 27 — Hot path. Profile slow interactions. Memoize, debounce, throttle. Virtualize long lists.

Days 28 to 30 — Measure. Re-run bundle analyzer. Compare to baseline. Verify Core Web Vitals improvement.

Most apps cut JavaScript shipped by 30 to 60 percent in this window, with proportional improvements to LCP and INP.

A real example — Dubai SaaS bundle audit

We audited a Dubai-based B2B SaaS app shipping 1.4 MB of JavaScript on initial load. Audit revealed: Moment.js, full Lodash, an unused charting library, polyfills for IE11 (which they did not support), and 14 third-party tags.

After 28 days — Moment replaced with date-fns, Lodash → specific imports, charting library lazy-loaded, polyfills removed, third-party tags audited and consolidated — bundle dropped to 380 KB. LCP improved from 4.1 seconds to 1.8 seconds on mobile. The full story is in our Dubai SaaS case study.

Common JavaScript performance mistakes

These are the patterns we see most often.

Not running bundle analysis. You cannot reduce what you cannot see.

Importing entire libraries when you need one function. import _ from "lodash" ships 70 KB. Import the specific function.

Polyfills for browsers you don't support. Strip them.

Synchronous third-party scripts. Use defer, lazyOnload, or Partytown.

Heavy components on the critical path. Charts, editors, maps should lazy-load.

Layout thrashing. Reading layout after writing forces synchronous layout. Batch reads, then writes.

Unmemoized expensive components. React re-renders on every parent update without memoization.

Long synchronous tasks. Anything over 50 ms blocks the main thread. Split or offload to workers.

Frequently asked questions

How much JavaScript should a typical page ship?

For marketing pages: under 100 KB compressed. For interactive apps: 200 to 400 KB compressed. Above 500 KB is hard to defend without specific reason.

Should I switch from React to a smaller framework?

Rarely worth the cost. Modern React with Server Components ships less client JavaScript than alternatives. The bigger wins come from optimizing how you use React, not switching frameworks.

Is webpack still the best bundler in 2026?

For most projects, no. Vite and Turbopack are faster and have better defaults. For Next.js, Turbopack is the default in Next.js 16.

Do I need to support IE11 in 2026?

Almost certainly not. IE11 usage is under 0.1 percent globally. The polyfill cost is not justified.

How important is JavaScript performance compared to image optimization?

Both matter. Images usually dominate LCP. JavaScript usually dominates INP. Optimize both for full Core Web Vitals improvement.

What's the best tool for finding unused JavaScript?

Chrome DevTools Coverage tab is the most direct. For static analysis, depcheck finds unused dependencies. For bundle composition, bundle analyzers.

Get a JavaScript performance audit

We audit JavaScript bundles free of charge. Within 48 hours we deliver a per-bundle breakdown of bloat, dead code, and optimization opportunities with expected impact.

Book a free 30-minute audit. We screen-share, walk through your bundle and Core Web Vitals, and you leave with a clear action plan.

Or explore our Web Development service for the full system we run on performance-focused client accounts.

Want these strategies applied to your business?

30 minutes of free audit with concrete recommendations tailored to your business.