A common pattern in JavaScript performance discussions: people argue about whether for is faster than forEach, whether Map beats objects for lookups, whether destructuring costs anything. These conversations are entertaining and almost never matter in production.

The actual bottlenecks in browser apps are bigger and more boring: network, hydration, render cost, and main-thread work. Fix those first. The rest is noise.

Network Usually Wins The Blame Game

The first request a user makes determines whether they stick around. If your bundle is 800KB gzipped, the user sees nothing for 4 seconds on a 3G connection. No forEach micro-optimization fixes that.

The unflattering truth: most apps ship more JavaScript than they need. React + dependencies + a router + a state library + a form library + an icon library + the actual app code adds up fast.

Three habits that move the number:

  1. Code-split by route. Vite, Next.js, Remix all do this with lazy() or dynamic import(). The login page shouldn't ship the dashboard's chart library.
  2. Audit your dependencies. bundle-analyzer shows what's actually shipping. The result is usually surprising — that 50KB date library you imported for one format() call.
  3. Use modern formats. Brotli compression, ES2020+ output, HTTP/2 or HTTP/3.

Cache your static assets aggressively (Cache-Control: public, max-age=31536000, immutable for hashed assets). The fastest network request is the one that never happens.

JavaScript Execution Can Block Users

Once the bundle has downloaded, it has to execute. Long synchronous tasks block the main thread, which means clicks don't register, scrolling stutters, and animations skip frames.

The Long Tasks API surfaces this:

JavaScript
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`long task: ${entry.duration.toFixed(0)}ms`, entry.name);
  }
});
observer.observe({ entryTypes: ['longtask'] });

Anything over 50ms shows up. The fix is breaking up work:

JavaScript
// 🐛 blocks for 800ms
items.forEach(processItem);

// ✅ yields between batches
async function processInChunks(items, work) {
  for (let i = 0; i < items.length; i++) {
    work(items[i]);
    if (i % 100 === 0 && 'scheduler' in window) {
      await scheduler.yield();
    }
  }
}

scheduler.yield() (now widely supported in modern browsers) is the cleanest "let the browser breathe" primitive. For older browsers, setTimeout(0) is the fallback.

Stacked horizontal bar chart breaking down a real page load from request to interactive. The largest segments — JS execution at 980ms and hydration at 560ms — are flagged as the biggest wins, while DNS and TLS at the start are marked as already fast and not worth optimizing.
Where the time actually goes. Optimize the biggest segment, not the prettiest one.

Hydration Has A Cost

Server-rendered apps (Next.js, Remix, SvelteKit) ship HTML that's already painted. Then JavaScript loads, runs, and "hydrates" the page — attaching event handlers, restoring state, taking over interactivity.

The user sees content fast (good). The page can feel unresponsive between paint and hydration (bad). Click a button before hydration completes and nothing happens.

Modern frameworks have several answers — partial hydration, islands architecture, server components — all variants of "ship less JavaScript that runs at hydration time." If your app is slow on metrics like INP (Interaction to Next Paint), hydration cost is usually the suspect.

Measure Before Optimizing Syntax

Core Web Vitals — LCP, CLS, INP — measure what users actually feel. Optimizing things they don't measure is decoration.

JavaScript
// real measurements, not micro-benchmarks
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.entryType, entry.name, entry.startTime);
  }
}).observe({ entryTypes: ['largest-contentful-paint', 'layout-shift', 'event'] });

Better: send these to RUM (Real User Monitoring) — your users on real devices in real network conditions are the only honest source of performance data. DevTools on a fiber connection lies to you.

Three tools that pay back: Chrome DevTools Performance tab (for understanding traces), Lighthouse (for catching regressions), and a RUM provider (for the truth).

What Actually Matters

In rough order of impact for typical web apps:

  1. Bundle size. Smaller is faster, always.
  2. Network waterfall. Critical resources first, defer the rest.
  3. Long main-thread tasks. Break them up.
  4. Hydration cost. Less JS on first interaction.
  5. Image and font sizes. Often bigger than the JavaScript.
  6. Render cost. CSS containment, avoiding layout thrash.

Map vs object, for vs forEach, + vs template literals — none of these matter at any normal scale.

Pro Tips

  1. Run Lighthouse in CI. Performance regressions get caught at PR time.
  2. Use <link rel="preload"> for critical resources — fonts, hero images.
  3. Defer non-critical JS with async or dynamic import().
  4. Profile before optimizing. Guesses are usually wrong.
  5. Send INP to your RUM. It's the new "fast feels fast" metric.

Final Tips

The performance work that pays back the most isn't the most fun. It's removing dependencies, splitting routes, deferring non-critical work, and watching the actual numbers users see. Reaching for requestIdleCallback is satisfying. Removing 200KB of unused JavaScript is what users actually feel.

Optimize what users measure. Skip the rest.

Good luck — and may your bundles only shrink 👊