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.

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-analyzerfor Next.js: shows treemap of bundle compositionsource-map-explorerfor generic webpack bundlesvite-bundle-analyzerfor Vite projectswebpack-bundle-analyzerfor 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) orDay.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-windowfor simple listsreact-virtuosofor 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.
Read next
The Lighthouse Audit Checklist: 50 Points We Check on Every Site
A comprehensive Lighthouse audit checklist — performance, accessibility, best practices, SEO. The 50-point list we run on every web performance engagement.
Third-Party Script Management: How to Stop Tags From Killing Your Site
A guide to managing third-party scripts — Google Tag Manager, chat widgets, analytics, marketing pixels. Strategies for deferring, replacing, and removing scripts.
Web Fonts Performance: Subsetting, font-display, and Preloading
A technical guide to web fonts performance — formats, subsetting, font-display, preloading, variable fonts, and the patterns that eliminate FOIT and FOUT.