← Back to Course Index

Module 2.6 — JavaScript Performance

Phase 2: Performance Engineering (Core Web Vitals)

JavaScript is the single largest source of performance problems on modern websites. It blocks the main thread, delays rendering, drives up INP (Interaction to Next Paint), balloons bundle sizes, and — critically for SEO — it can prevent content from being visible to both users and crawlers in a timely fashion. This module teaches you to reason about JavaScript performance the way an engineer does: measure first, understand causality, then fix with precision.


2.6.1 — Why JavaScript Performance Is an SEO Problem

Most SEOs think of JS performance as a user-experience concern. It is that — but it is also a direct ranking factor through Core Web Vitals, and an indirect one through crawl efficiency and rendering delays.

The core principle: every millisecond of unnecessary JavaScript work has a compounding cost — to real users, to INP scores, and to how completely and quickly a crawler can extract your content.


2.6.2 — Understanding the Main Thread

JavaScript in the browser runs on a single main thread. That same thread is also responsible for rendering, layout, paint, and responding to user input. When your JavaScript is running, it blocks everything else.

Long Tasks

A long task is any task that occupies the main thread for more than 50ms. During a long task:

You can visualize long tasks in Chrome DevTools:

  1. Open DevTools → Performance tab.
  2. Click Record, interact with the page, stop recording.
  3. Long tasks appear in the Main thread lane with a red triangle in the top-right corner of the task bar.
  4. Click a long task to see its call stack — exactly which JS functions caused the delay.
// In the Performance panel, look for entries like:
// [Task] 312ms  ← red triangle = long task
//   Evaluate Script
//     (anonymous) vendor.bundle.js:1
//       heavyDataTransform() app.bundle.js:4521
// This tells you exactly which function is responsible.

The Event Loop Revisited

Recall from Module 0.3b: the event loop processes one task at a time. If a task runs for 800ms, any queued user interactions must wait 800ms before being handled. This is why a slow JSON parse or a large array transformation during page load can cause terrible INP even if the animation looks smooth.


2.6.3 — Bundle Size Budgets

Every byte of JavaScript must be: downloaded, parsed, compiled, and executed. Parsing and compilation alone are expensive — they happen on the main thread. A 1MB JavaScript bundle does not cost the same as a 1MB image; the image is decoded off-thread, while JS processing is synchronous and blocking.

Setting a Budget

A practical starting budget for most production sites:

These are targets, not laws — but treating them as hard constraints forces architectural decisions that consistently improve real-user metrics.

Measuring Bundle Size

# Using webpack-bundle-analyzer (if your build uses webpack/Next.js):
npx next build
npx next-bundle-analyzer

# Or with Lighthouse CLI:
npx lighthouse https://example.com --output=html --view

# Lighthouse will flag:
# - "Reduce unused JavaScript"
# - "Avoid enormous network payloads"
# - "Minimize main-thread work"

In Chrome DevTools, the Coverage tab (Cmd+Shift+P → "Show Coverage") shows exactly which bytes of each JS file were executed during page load. Red sections = unused bytes sent to the browser. This is the most actionable view for identifying bloat.


2.6.4 — Code Splitting

Code splitting means breaking one large JavaScript bundle into multiple smaller chunks that are loaded only when needed. Instead of sending every feature to every user on every page, you send only what that page actually requires.

Route-Based Code Splitting

The most impactful type. Each route (URL) loads only its own JavaScript, not the entire application. Next.js does this automatically per page. In vanilla Webpack, you configure entry points per route.

// Next.js: automatic per-page splitting
// pages/product/[slug].js only loads ProductPage.js bundle
// pages/blog/[slug].js only loads BlogPost.js bundle
// Shared code goes into a common chunk — sent once, cached

Component-Level Splitting (Dynamic Imports)

Defer the loading of expensive components until they are needed (e.g., when a modal is opened, not on initial page load).

// React / Next.js: dynamic import
import dynamic from 'next/dynamic';

// This component's JS is NOT included in the initial bundle.
// It loads only when <ReviewModal> is first rendered.
const ReviewModal = dynamic(() => import('../components/ReviewModal'), {
  loading: () => <p>Loading...</p>,
});

// SEO implication: if ReviewModal contains content crawlers need,
// use SSR: true so it renders server-side in the initial HTML.
const ReviewModalSSR = dynamic(() => import('../components/ReviewModal'), {
  ssr: true,
});

What Code Splitting Fixes


2.6.5 — Tree Shaking

Tree shaking is the bundler's process of removing exported code that is never imported anywhere in your application. Dead code elimination. It only works with ES module syntax (import/export), not CommonJS (require/module.exports).

// ❌ This imports the ENTIRE lodash library (~70KB gzipped):
import _ from 'lodash';
const result = _.groupBy(data, 'category');

// ✅ This imports ONLY groupBy (~3KB):
import groupBy from 'lodash/groupBy';
const result = groupBy(data, 'category');

// ✅ Even better: lodash-es (full ES module version, tree-shakeable):
import { groupBy } from 'lodash-es';
const result = groupBy(data, 'category');

Tree shaking is a bundler-level optimization (Webpack, Rollup, Vite, esbuild). Your job as an SEO engineer is to ensure it is enabled and to audit whether large library imports are being correctly shaken in bundle analysis output.

Checking if Tree Shaking Works

In webpack-bundle-analyzer, if you see an entire library module in a chunk despite only using one function, tree shaking is not working for that import. Common reasons:


2.6.6 — Deferring Third-Party Scripts

Third-party scripts — analytics, tag managers, chat widgets, A/B testing tools, ad networks, heatmaps — are among the most destructive forces in web performance. They run code you did not write, on infrastructure you do not control, and they are frequently the cause of long tasks, layout shifts, and inflated INP.

The Three Loading Attributes

<!-- Parser-blocking: pauses HTML parsing until script loads + executes -->
<script src="analytics.js"></script>

<!-- async: downloads in parallel, executes as soon as ready (may block render) -->
<script async src="analytics.js"></script>

<!-- defer: downloads in parallel, executes AFTER HTML parsing is complete -->
<script defer src="analytics.js"></script>

For third-party scripts that do not need to run before the page is interactive: always use defer or load them dynamically after the load event. Never use parser-blocking script tags for third-party resources.

Loading After User Interaction (Facade Pattern)

For heavy components (YouTube embeds, Intercom, Drift, Typeform), load the actual third-party code only when the user interacts with a lightweight placeholder. This is the facade pattern.

// Facade pattern: YouTube embed
// Show a static thumbnail image.
// Only load the YouTube iframe SDK when the user clicks play.

const playButton = document.querySelector('.video-facade');
playButton.addEventListener('click', () => {
  const iframe = document.createElement('iframe');
  iframe.src = 'https://www.youtube.com/embed/VIDEO_ID?autoplay=1';
  iframe.allow = 'autoplay';
  playButton.replaceWith(iframe);
  // YouTube SDK loads only now — not on initial page load
}, { once: true });

The Lighthouse audit "Reduce the impact of third-party code" directly identifies the worst offenders and their main-thread cost. Use it as your prioritized fix list.

Google Tag Manager — The Hidden Culprit

GTM itself is small. But every tag fired through GTM adds to main-thread cost. Audit your GTM container:


2.6.7 — Long Task Profiling in Practice

Knowing long tasks exist is not enough. You need to identify which code is causing them and then eliminate or break them up. Here is the full workflow.

Step 1: Capture a Profile

  1. Open Chrome DevTools → Performance tab.
  2. Enable "CPU throttling" (4x or 6x slowdown) to simulate a mid-range Android device.
  3. Click Record, simulate the interaction that feels slow (click, type, scroll), stop after 3–5 seconds.
  4. Examine the Main thread timeline. Identify long tasks (red triangle).
  5. Click into the task → look at the Bottom-Up or Call Tree tabs to find the hottest function.

Step 2: Break Up Long Tasks

If a long task is doing necessary work (e.g., processing a large data set), you can split it into chunks using setTimeout or the Scheduler API to yield back to the browser between chunks.

// ❌ Synchronous: blocks the main thread for the entire loop
function processLargeArray(items) {
  for (const item of items) {
    heavyTransform(item); // 800ms total — one long task
  }
}

// ✅ Yielding: breaks work into chunks, releasing the thread between them
async function processInChunks(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(heavyTransform);

    // Yield to the browser: allows rendering and input handling
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

// Even better: use the Scheduler API when available
async function processWithScheduler(items) {
  for (const item of items) {
    if (navigator.scheduling?.isInputPending()) {
      // User is trying to interact — yield immediately
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    heavyTransform(item);
  }
}

Step 3: Move Work Off the Main Thread

JavaScript that does not need to access the DOM can run in a Web Worker — a separate thread that runs in parallel, leaving the main thread free for rendering and interaction.

// main.js — send data to a worker
const worker = new Worker('/workers/data-processor.js');

worker.postMessage({ items: largeDataArray });

worker.onmessage = (event) => {
  // Receive results back from the worker
  renderResults(event.data.results);
};

// workers/data-processor.js — runs off the main thread
self.onmessage = (event) => {
  const { items } = event.data;
  const results = items.map(heavyTransform); // Does NOT block the main thread
  self.postMessage({ results });
};

Web Workers are ideal for: large JSON parsing, cryptographic operations, data filtering/sorting, image manipulation (via OffscreenCanvas), and any CPU-bound computation.

SEO note: Web Workers have no SEO implication — they cannot modify the DOM, so no content is hidden from crawlers by moving work to a worker. This is a pure performance optimization.


2.6.8 — Reducing Hydration Cost

Hydration is the process by which a server-rendered HTML page is "taken over" by the client-side JavaScript framework, attaching event listeners and making the page interactive. In frameworks like React, Next.js, and Vue, hydration is a significant source of main-thread work on initial page load.

Why Hydration Hurts INP

During hydration, the framework:

  1. Parses and executes the JavaScript bundle.
  2. Reconciles the virtual DOM with the real DOM.
  3. Attaches all event listeners.

This can take hundreds of milliseconds on slower devices, during which the page looks interactive (content is visible) but is not. A user who clicks during hydration will see no response — a classic INP failure.

Strategies to Reduce Hydration Cost

// Next.js App Router: React Server Component
// This component runs ONLY on the server — zero JS sent to client
// No hydration cost, no event listeners needed
async function ProductDescription({ productId }) {
  const product = await db.products.findById(productId);
  // Rendered to HTML on the server, no client JS
  return <p>{product.description}</p>;
}

// 'use client' directive marks components that DO hydrate
'use client';
function AddToCartButton({ productId }) {
  // This component's JS is sent to the client and hydrated
  return <button onClick={() => addToCart(productId)}>Add to Cart</button>;
}

SEO impact of reducing hydration: Less JS shipped to the client means lower INP, faster Time to Interactive, and — because server components render to static HTML — content is always present in the raw HTML that crawlers receive without needing to execute any JavaScript.


2.6.9 — Preconnect, Preload, and Prefetch

Resource hints instruct the browser to perform network work earlier than it naturally would, reducing the latency felt when a resource is actually needed.

<!-- preconnect: establish a connection to a third-party origin early.
     Use for domains where you'll fetch critical resources (fonts, APIs). -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- preload: fetch a specific resource with high priority, earlier than discovered.
     Use for LCP images, critical fonts, critical CSS. -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<link rel="preload" as="font" href="/fonts/brand.woff2" type="font/woff2" crossorigin>
<link rel="preload" as="script" href="/critical-chunk.js">

<!-- prefetch: fetch a resource the user is likely to need soon (next page).
     Low priority. Does not block anything. -->
<link rel="prefetch" href="/next-step.js">

The Most Impactful Use: Preloading the LCP Image

If your LCP element is a hero image (especially one set as a CSS background or discovered late in the HTML), the browser cannot know to fetch it until the stylesheet is parsed. Preloading it moves the fetch to the very beginning of the waterfall.

<!-- Place in <head>, before stylesheets -->
<link
  rel="preload"
  as="image"
  href="/images/hero-800.webp"
  imagesrcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w"
  imagesizes="(max-width: 600px) 400px, 800px"
  fetchpriority="high"
>

This single tag can reduce LCP by 300–700ms on real-world pages. Verify the impact in WebPageTest's waterfall — the image request should now appear near the very top of the waterfall, not halfway through.

What Not to Over-Preload

Preloading too many resources creates bandwidth contention — every preloaded resource competes with resources the browser already decided to fetch with high priority. Only preload resources that are: on the critical path, discovered late, and needed immediately on this page.


2.6.10 — Measuring JavaScript Performance: The Full Workflow

Never guess. Measure, diagnose, fix, re-measure. Here is the complete toolchain.

Lab Tools (Synthetic / Controlled Conditions)

Field Data (Real Users)

// Instrumenting INP with the web-vitals library
import { onINP, onLCP, onCLS } from 'web-vitals';

function sendToAnalytics({ name, value, rating, id }) {
  // Send to your analytics endpoint or GA4
  fetch('/analytics', {
    method: 'POST',
    body: JSON.stringify({ metric: name, value, rating, id }),
    keepalive: true, // Ensures delivery even if page unloads
  });
}

onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);

Attribution: Finding the Cause of Bad INP

The web-vitals library (v3+) supports attribution — it can tell you which element was interacted with, what the interaction was, and which phase (input delay, processing time, or presentation delay) dominated the INP score.

import { onINP } from 'web-vitals/attribution';

onINP(({ name, value, attribution }) => {
  const { eventTarget, eventType, processingDuration, inputDelay } = attribution;
  console.log(`INP: ${value}ms`);
  console.log(`Triggered by: ${eventType} on ${eventTarget}`);
  console.log(`Processing duration: ${processingDuration}ms`); // ← your JS cost
  console.log(`Input delay: ${inputDelay}ms`); // ← main thread was busy
});

If processingDuration is high, your event handler JS is slow. If inputDelay is high, the main thread was occupied with something else (a long task, hydration, analytics firing) when the user clicked. Both lead to the same INP failure but require different fixes.


2.6.11 — Putting It Together: A JavaScript Performance Audit Checklist

Use this checklist when auditing any site for JavaScript performance. Each item maps to a specific metric or SEO risk.


Module Summary

JavaScript performance is not one problem — it is a family of related problems: too much code, too much work on the main thread, work happening at the wrong time, and resources arriving too late. The engineer's approach is always the same: instrument with real-user data, reproduce in a lab, trace to the specific cause in the call stack, fix with a targeted technique, re-measure with field data.

The table below maps the symptoms you will encounter to the techniques covered in this module:

Hands-On Exercise

  1. Select a real page with a failing or "Needs Improvement" INP score in PageSpeed Insights field data. If you don't have one, use WebPageTest to simulate interactions on a JS-heavy public site.
  2. Record a Performance profile in DevTools (6x CPU throttle, simulate a button click or form interaction). Identify all long tasks. Find the top function in the call tree.
  3. Run Lighthouse and the Coverage tab. Identify the three largest sources of unused JavaScript.
  4. Draft a written diagnosis: for each issue, name the metric it affects, the specific cause (function name, library, script URL), and the targeted fix (code split, defer, remove, web worker, yielding, facade pattern).
  5. Implement at least one fix. Re-run Lighthouse and compare before/after numbers. Document the delta.

Milestone

You are ready to move on when you can: open any page in DevTools, profile its main-thread activity, identify which JavaScript is causing long tasks or bundle bloat, prescribe the correct technique from this module to address each issue, and articulate precisely how that fix improves INP, LCP, or both — with before/after numbers to support the claim.