/ JS Checker Fixes / Render-Blocking JS

How to Fix Render-Blocking JavaScript

JavaScript blocks render by default. A script tag in <head> halts HTML parsing while it downloads and executes. Multiple synchronous scripts compound — each adds milliseconds to seconds of delay before First Contentful Paint. The fix is simple attribute additions (defer, async) for most scripts, with code splitting for the heavy modules. This guide covers the script attributes, third-party patterns, and module splitting. For related fixes, see the JS Checker Fixes index.

1. Identify render-blocking scripts

Step 1
Lighthouse audit
DevTools → Lighthouse → Generate. Look for "Eliminate render-blocking resources" opportunity. Lists each blocking script with estimated savings.
Step 2
DevTools Network waterfall
Network tab → reload. Scripts in the head with no async/defer attributes block the parser. Look at the waterfall: scripts that finish loading before FCP are blocking it.

2. Understand defer vs async

<!-- Default: synchronous, blocks parser -->
<script src="/app.js"></script>

<!-- async: parallel download, executes ASAP, order unpredictable -->
<script src="/app.js" async></script>

<!-- defer: parallel download, executes after parsing, in document order -->
<script src="/app.js" defer></script>

<!-- module: defer by default, plus module semantics -->
<script src="/app.js" type="module"></script>

Pick based on dependencies

3. Add defer to non-critical app scripts

<!-- Before -->
<head>
  <script src="/app.js"></script>
  <script src="/analytics.js"></script>
  <script src="/chat-widget.js"></script>
</head>

<!-- After -->
<head>
  <script src="/app.js" defer></script>
  <script src="/analytics.js" async></script>
  <script src="/chat-widget.js" defer></script>
</head>

All three scripts now download in parallel with HTML parsing. app.js and chat-widget.js wait for parsing to complete; analytics.js fires as soon as it's downloaded (often before parsing is done).

4. Move third-party scripts to async or defer

Analytics, chat widgets, advertising, A/B test tools, recommendation engines — most third-party scripts have no need to block render.

Google Analytics / GA4

<!-- Google's recommended snippet uses async, keep it -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>

Chat widgets (Intercom, Drift, etc.)

Move to defer or load on user interaction:

// Load chat widget only when user opens menu or after 5 seconds idle
function loadChat() {
  const script = document.createElement('script');
  script.src = 'https://widget.intercom.io/...';
  script.async = true;
  document.body.appendChild(script);
}
// Trigger on user interaction
setTimeout(loadChat, 5000);
// or
document.querySelector('.menu-btn').addEventListener('click', loadChat);

Tag managers

GTM and similar default to async, which is correct. Don't change them. Inside GTM, configure individual tags to fire on the events that need them, not "All Pages" by default.

5. Use modulepreload for module dependencies

ES modules with deep dependency chains can serialise downloads — the browser only learns about each module when parsing the previous one. Fix with modulepreload:

<head>
  <link rel="modulepreload" href="/app.js">
  <link rel="modulepreload" href="/components.js">
  <link rel="modulepreload" href="/utils.js">
  <script type="module" src="/app.js"></script>
</head>

Browser starts downloading all three modules in parallel as soon as it sees the link tags, before parsing app.js to discover its imports.

6. Code-split heavy modules

Don't ship the whole app in one bundle. Modern build tools support dynamic imports that load code on demand.

// Static import: everything bundles into initial JS
import Chart from 'chart.js/auto';

// Dynamic import: separate chunk, loads when called
async function showChart() {
  const { default: Chart } = await import('chart.js/auto');
  new Chart(...);
}
button.addEventListener('click', showChart);

Webpack, Rollup, Vite, esbuild all handle dynamic imports automatically — split each into its own chunk.

Route-level splitting in React/Vue/Svelte

// React Router lazy routes
import { lazy, Suspense } from 'react';
const Product = lazy(() => import('./pages/Product'));

<Suspense fallback={<Loading />}>
  <Product />
</Suspense>

7. Inline critical JS

For very small scripts (under 1-2 KB) needed before render, inline them rather than referencing an external file. Saves the round-trip.

<head>
  <script>
    // Theme preference applied synchronously to prevent flash
    const theme = localStorage.getItem('theme') || 'auto';
    document.documentElement.dataset.theme = theme;
  </script>
</head>

Keep inlined JS tiny. Anything over 2 KB usually loads faster as a cached external file on repeat visits.

8. Measure the improvement

Step 1
Lab metrics
Re-run Lighthouse. Expected improvements:
  • FCP: -200ms to -1500ms
  • LCP: -200ms to -1000ms
  • TBT (Total Blocking Time): can drop dramatically with heavy third-party scripts moved to async
  • Performance score: +5 to +20 points
💡 Defer all your app code; async all third-party. That single rule fixes most render-blocking JS issues. The rest is splitting and modulepreload tuning.

⚙️ Re-run the JS Checker

Verify render-blocking JS findings are cleared.

Run JS Checker →
Related Guides: JS Checker Fixes  ·  Fix JS Bundle Size  ·  Fix Third-Party Tags  ·  JS Checker Guide
💬 Got a problem?