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.
<!-- 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>
<!-- 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).
Analytics, chat widgets, advertising, A/B test tools, recommendation engines — most third-party scripts have no need to block render.
<!-- Google's recommended snippet uses async, keep it --> <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
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);
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.
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.
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.
// React Router lazy routes
import { lazy, Suspense } from 'react';
const Product = lazy(() => import('./pages/Product'));
<Suspense fallback={<Loading />}>
<Product />
</Suspense>
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.