Guideintermediate

JavaScript Performance Guide: Reduce Main Thread Blocking

A practical guide to JavaScript performance optimization: code splitting, task scheduling, bundle analysis, and reducing Total Blocking Time for better Core Web Vitals.

Rankwise Team·Updated Mar 12, 2026·5 min read

JavaScript is the single biggest contributor to main thread blocking on most websites. Every millisecond the main thread spends executing JavaScript is a millisecond it cannot respond to user input. This guide covers the practical techniques that reduce blocking time, improve Total Blocking Time (TBT), and keep Interaction to Next Paint (INP) fast.


Why JavaScript performance matters for SEO

Google uses Core Web Vitals as ranking signals. Two of the three vitals are directly affected by JavaScript execution:

  • Largest Contentful Paint (LCP): render-blocking scripts delay the largest element from painting
  • Interaction to Next Paint (INP): long JavaScript tasks delay response to clicks, taps, and key presses

TBT — the lab proxy for interactivity — carries 30% of the Lighthouse performance score weight. Reducing JavaScript execution time is often the highest-leverage performance optimization available.


Step 1: Measure before you optimize

Find your long tasks

Open Chrome DevTools → Performance tab → record a page load. Look for tasks longer than 50ms (shown with a red triangle). Each one contributes to TBT.

Alternatively, use the Long Tasks API:

new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(`Long task: ${entry.duration}ms`, entry.attribution)
  }
}).observe({ type: "longtask", buffered: true })

Identify the source

Long tasks fall into three buckets:

  1. First-party code: your application logic, framework hydration, state initialization
  2. Third-party scripts: analytics, ads, chat widgets, A/B testing tools
  3. Browser work: style recalculation, layout, paint (triggered by JavaScript)

Attribute each long task to its source before deciding on a fix.


Step 2: Reduce what you ship

Code splitting by route

Load only the JavaScript needed for the current page:

// Next.js does this automatically per page
// For other frameworks, use dynamic imports
const Dashboard = lazy(() => import("./pages/Dashboard"))
const Settings = lazy(() => import("./pages/Settings"))

Route-level splitting is the highest-impact change for most applications. A homepage should not download the settings page bundle.

Tree shaking

Import only what you use:

// Loads the entire library (~70KB)
import _ from "lodash"

// Loads only debounce (~1KB)
import debounce from "lodash/debounce"

Check your bundler output. Webpack Bundle Analyzer or npx vite-bundle-visualizer reveal what is actually included.

Remove unused dependencies

Audit your package.json. Libraries added months ago for a feature that was reverted still ship to users. Use npx depcheck to find unused packages.


Step 3: Defer non-critical scripts

Third-party scripts

Move analytics, chat widgets, and tracking scripts out of the critical path:

<!-- Bad: blocks rendering -->
<script src="https://analytics.example.com/tracker.js"></script>

<!-- Better: loads after HTML parsing -->
<script defer src="https://analytics.example.com/tracker.js"></script>

<!-- Best: loads during idle time -->
<script>
  requestIdleCallback(() => {
    const s = document.createElement("script")
    s.src = "https://analytics.example.com/tracker.js"
    document.body.appendChild(s)
  })
</script>

For heavy embeds (YouTube, maps, chat), use a facade pattern: show a static preview and load the real embed only when the user interacts.

First-party feature code

Not every feature needs to initialize on page load:

// Load PDF export only when user clicks the button
exportButton.addEventListener("click", async () => {
  const { exportToPDF } = await import("./pdf-exporter")
  exportToPDF(document)
})

Step 4: Break up long tasks

A single 300ms task blocks the main thread for 300ms. Three 100ms tasks with yields between them allow the browser to handle user input in the gaps.

The yield pattern

function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0))
}

async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i])

    // Yield every 5 items
    if (i % 5 === 0) {
      await yieldToMain()
    }
  }
}

scheduler.yield() (modern browsers)

async function processItems(items) {
  for (const item of items) {
    processItem(item)
    await scheduler.yield() // Yields and resumes with same priority
  }
}

requestIdleCallback for background work

function processInBackground(items) {
  let index = 0

  function processNext(deadline) {
    while (index < items.length && deadline.timeRemaining() > 5) {
      processItem(items[index++])
    }

    if (index < items.length) {
      requestIdleCallback(processNext)
    }
  }

  requestIdleCallback(processNext)
}

Step 5: Optimize framework hydration

Server-rendered pages that hydrate on the client often produce large blocking tasks during hydration.

Strategies

  • Partial hydration: only hydrate interactive components (Astro islands, React Server Components)
  • Progressive hydration: hydrate above-the-fold components first, defer the rest
  • Lazy hydration: hydrate components when they enter the viewport or receive user interaction
// Astro: only hydrate when visible
<InteractiveWidget client:visible />

// React 18+: Selective hydration with Suspense
<Suspense fallback={<StaticFallback />}>
  <HeavyInteractiveComponent />
</Suspense>

Step 6: Move heavy computation off the main thread

Web Workers

// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url))

worker.postMessage({ type: "analyze", data: largeDataset })
worker.onmessage = e => {
  updateUI(e.data.results)
}

// worker.js
self.onmessage = e => {
  if (e.data.type === "analyze") {
    const results = heavyAnalysis(e.data.data)
    self.postMessage({ results })
  }
}

Good candidates for Web Workers:

  • Data transformation and filtering
  • Search/sort operations on large datasets
  • Image processing
  • Markdown or text parsing
  • CSV/JSON processing

Step 7: Audit and prevent regressions

Bundle size budgets

Set maximum bundle sizes in your build tool:

// webpack.config.js
module.exports = {
  performance: {
    maxAssetSize: 150000, // 150KB per asset
    maxEntrypointSize: 250000, // 250KB per entry
    hints: "error"
  }
}

Lighthouse CI in your pipeline

# .github/workflows/perf.yml
- name: Lighthouse CI
  run: |
    npm install -g @lhci/cli
    lhci autorun --config=lighthouserc.js

Monitor field data

Lab tests run on fast hardware. Real users run on 3-year-old phones on 3G. Track TBT and INP in the field using CrUX data or the web-vitals library to catch regressions that lab tests miss.


Common mistakes

Mistake 1: Optimizing the wrong thing

Profile first. The largest long task might come from a third-party script, not your code. Removing a 5KB first-party module while a 200KB analytics bundle runs synchronously misses the real problem.

Mistake 2: Over-splitting

Splitting every component into its own chunk creates excessive HTTP requests and reduces cache efficiency. Split by route or feature boundary, not by individual component.

Mistake 3: Ignoring the execution cost of frameworks

A 50KB framework bundle that takes 200ms to parse and execute is more expensive than a 100KB bundle that takes 50ms. Measure execution time, not just transfer size.

Mistake 4: Using synchronous patterns in async contexts

// Bad: JSON.parse blocks the main thread on large payloads
const data = JSON.parse(hugeString)

// Better: stream-parse or move to a Worker
worker.postMessage({ type: "parse", raw: hugeString })

Frequently Asked Questions

What is a good TBT target?

Under 200ms is "good" by Lighthouse standards. Complex applications should aim for under 300ms on critical user journeys. Above 600ms is considered poor.

Does JavaScript affect LCP directly?

Yes. Render-blocking scripts delay LCP by preventing the browser from painting content. Client-rendered content (no SSR) cannot show an LCP element until JavaScript executes and inserts it into the DOM.

Should I switch to a lighter framework?

Framework choice matters less than how you use it. A well-optimized React app with code splitting outperforms a poorly configured vanilla JS site. Focus on splitting, deferring, and yielding before considering a framework migration.

How do Web Workers affect bundle size?

Worker code is loaded separately from the main bundle and does not block the main thread during parsing. The transfer cost is the same, but the execution cost moves off the critical path.

Part of the SEO Fundamentals topic

Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.