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.
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.
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.
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:
// 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.
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.
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.
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.
# 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.
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.
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
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,
});
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.
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:
require), which bundlers cannot statically analyse.babel config is transpiling ES modules to CommonJS before the bundler sees them.sideEffects: false flag is missing from the library's package.json.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.
<!-- 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.
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.
GTM itself is small. But every tag fired through GTM adds to main-thread cost. Audit your GTM container:
load event, not DOMContentLoaded.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.
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);
}
}
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.
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.
During hydration, the framework:
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.
IntersectionObserver.
__NEXT_DATA__ script tag to hydrate client state, that JSON must be parsed synchronously.
Fetch data client-side after hydration instead, or use streaming/RSC.
// 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.
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">
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.
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.
Never guess. Measure, diagnose, fix, re-measure. Here is the complete toolchain.
web-vitals JavaScript library — instrument your pages to
collect INP, LCP, and CLS from real users and send to your analytics stack. Essential for
diagnosing which specific pages have INP failures in production.
// 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);
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.
Use this checklist when auditing any site for JavaScript performance. Each item maps to a specific metric or SEO risk.
load event.
async or defer? Are any critical scripts accidentally deferred?
<head>?
Confirm in the WebPageTest waterfall that the image request starts in the first 500ms.
web-vitals attribution to identify the interaction and phase driving poor INP.
<script> tags without
async/defer in the <head> that are not genuinely
critical? They block HTML parsing for every user, every page load.
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:
<head>; check if CSR is delaying content paint.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.