To speed up JavaScript, ship less of it, load it without blocking the page, cache it well, and keep it from blocking the main thread while it runs. In practice that means using defer or modules, tree shaking, minification and compression, code splitting, long-lived caching, breaking up long tasks and moving heavy work to Web Workers. The techniques below cover each one, with numbers from tests I ran.
JavaScript is usually the most expensive resource on a page. Unlike an image, it has to be downloaded, then parsed, compiled and executed on the main thread, the same thread that responds to clicks and taps. So slow JavaScript hurts in two ways: pages take longer to load, and they feel sluggish after they load.
Two Kinds of Slow: Loading and Responsiveness
- Loading: too much JavaScript, or scripts that block rendering, delay when content appears. This shows up in Largest Contentful Paint (LCP).
- Responsiveness: long-running JavaScript blocks the main thread, so clicks and key presses wait. Google measures this with Interaction to Next Paint (INP), which replaced First Input Delay as a Core Web Vital in March 2024. A good INP is 200 milliseconds or less.
Measure before you optimize. Run your page through PageSpeed Insights or Lighthouse, record a trace in the Chrome DevTools Performance panel to find long tasks, and open the Coverage tab to see how much of each script is never used on the page.
1. Load Scripts with defer, async or type=”module”
A plain <script src> in the head stops HTML parsing until the script downloads and runs. Use one of these instead:
<!-- Downloads in parallel, runs after the HTML is parsed, in order -->
<script src="/app.js" defer></script>
<!-- Downloads in parallel, runs as soon as it arrives, in any order -->
<script src="/analytics.js" async></script>
<!-- Modules are deferred by default -->
<script type="module" src="/main.js"></script>| Attribute | Blocks parsing? | Execution order | Use for |
|---|---|---|---|
| none | Yes | In order, immediately | Rarely needed today |
defer | No | In document order, after parsing | Your app code |
async | No | Whenever each one finishes downloading | Independent scripts like analytics |
type="module" | No (deferred) | In order, after parsing | Modern code using import |
2. Ship Less Code with Tree Shaking
Bundlers such as Vite, Rollup, webpack and esbuild can remove code you never use (“tree shaking”), but only when they can see what you import, which means ES module import syntax and libraries published as ES modules. I bundled a one-line app that only uses debounce, importing it two ways with esbuild 0.28 and lodash 4.18:
// Version A: imports the whole CommonJS library
import _ from 'lodash';
document.querySelector('#search').addEventListener('input', _.debounce((e) => console.log(e.target.value), 300));
// Version B: imports one function from the ES module build
import { debounce } from 'lodash-es';
document.querySelector('#search').addEventListener('input', debounce((e) => console.log(e.target.value), 300));| Build | Size | Gzipped |
|---|---|---|
A: lodash, not minified | 237,677 bytes | 42,594 bytes |
A: lodash, minified | 73,924 bytes | 26,725 bytes |
B: lodash-es, not minified | 10,912 bytes | 2,799 bytes |
B: lodash-es, minified | 3,531 bytes | 1,765 bytes |
Same feature, about 15 times less to download after compression. Check your bundle with a visualizer (for example rollup-plugin-visualizer for Vite, or esbuild’s --metafile with its online analyzer) to find large dependencies, and replace heavy libraries with smaller ones or native browser APIs where you can.
3. Minify and Compress
Minification removes whitespace and comments and shortens variable names; the table above shows it cutting the lodash-es bundle from 10.9 KB to 3.5 KB. Every modern bundler does this in production builds. Then make sure your server or CDN compresses JavaScript with Brotli or gzip, which typically shrinks text files by a further 60–80%. You can confirm it in the DevTools Network panel by checking for a content-encoding: br or gzip response header. For more detail, see how minifying resources improves performance and SEO.
4. Split Code and Load It on Demand
Code that is only needed after a user action, such as a chart, an editor or a modal, should not be in the initial bundle. A dynamic import() tells the bundler to put it in a separate file that is downloaded only when needed:
document.querySelector('#show-chart').addEventListener('click', async () => {
const { Chart } = await import('chart.js/auto'); // downloaded only on click
new Chart(document.querySelector('#chart'), {
type: 'bar',
data: { labels: ['A', 'B'], datasets: [{ label: 'Sales', data: [3, 5] }] },
});
});With Chart.js 4.5 and esbuild’s code splitting, the version that imported Chart.js at the top produced a 205,121-byte initial bundle (70,775 bytes gzipped). The dynamic-import version’s initial file was 313 bytes; the 205 KB chart chunk loads only when someone clicks the button. Frameworks do the same thing automatically per route (Next.js, Nuxt, SvelteKit) or with helpers like React’s lazy().
5. Cache Aggressively with Hashed File Names
Build tools can add a content hash to file names (app.3f9a1c.js). Because the name changes whenever the content changes, you can tell browsers to cache these files for a year:
Cache-Control: public, max-age=31536000, immutableReturning visitors then load your JavaScript from disk with no network request at all. Keep the HTML itself on a short cache or no-cache so it always points to the latest file names. Splitting rarely-changing vendor code into its own chunk means an app update doesn’t force users to download the libraries again.
6. Serve Scripts from a CDN
A CDN serves your files from servers close to each visitor, cutting latency, and usually handles HTTP/2 or HTTP/3 and compression for you. Most hosting platforms include one. I explain how they work in how a content delivery network works. Also consider resource hints such as <link rel="preload"> or fetchpriority for the few scripts that are truly critical; see how to use fetchpriority.
7. Break Up Long Tasks
Any task that runs on the main thread for more than 50 ms is a “long task”: while it runs, the browser can’t respond to input. The fix is to split the work into chunks and give the browser a chance to handle input between them:
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processInChunks(items) {
let deadline = performance.now() + 10; // work for ~10 ms at a time
for (const item of items) {
work(item);
if (performance.now() > deadline) {
await yieldToMain(); // let clicks and rendering happen
deadline = performance.now() + 10;
}
}
}To check the difference, I processed 3 million items in headless Brave (Chromium 155) and used Puppeteer to click a button 100 ms after the work started:
| Approach | Longest task | Click waited | Total work time |
|---|---|---|---|
| One loop | 523–548 ms (one long task) | 433–451 ms | 523–549 ms |
Chunks with setTimeout yielding | No long tasks | 6–7 ms | 1,182–1,246 ms |
The click went from waiting almost half a second to responding instantly. The cost is that the total work took about twice as long because of the time spent yielding, which is usually the right trade for anything the user is not waiting on directly.
Chromium browsers and Firefox also support scheduler.yield(), which is designed for this and resumes your task ahead of other queued work (Safari did not support it at the time of writing, so you need a fallback). In my headless test, though, a click sent through Puppeteer still waited for the whole scheduler.yield() loop to finish (about 745 ms). I haven’t confirmed whether that also happens with real user input, so measure INP on your own pages before switching to it.
8. Debounce and Throttle Frequent Events
Events like input, scroll and resize can fire dozens of times a second. Debouncing runs the handler only after the events stop (good for search-as-you-type); throttling runs it at most once per interval (good for scroll position):
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
searchInput.addEventListener('input', debounce((e) => search(e.target.value), 300));For scroll and touch listeners that never call preventDefault(), add { passive: true } so the browser doesn’t wait for your handler before scrolling. For visibility checks such as lazy loading or infinite scroll, use IntersectionObserver instead of scroll listeners.
9. Avoid Layout Thrashing
Reading layout properties such as offsetHeight or getBoundingClientRect() right after changing styles forces the browser to recalculate layout immediately. Doing that inside a loop forces a layout on every iteration:
// Slow: read, write, read, write... forces layout every time
for (const box of boxes) {
box.style.height = box.parentElement.offsetHeight + 'px';
}
// Faster: do all reads first, then all writes
const heights = boxes.map((box) => box.parentElement.offsetHeight);
boxes.forEach((box, i) => { box.style.height = heights[i] + 'px'; });Use requestAnimationFrame for visual updates, animate transform and opacity rather than layout properties, and keep the DOM small; reducing DOM size makes every layout cheaper.
10. Move Heavy Work to a Web Worker
Web Workers run JavaScript on a separate thread, so CPU-heavy work such as parsing large files, image processing or complex calculations never blocks the page:
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.postMessage(largeDataset);
worker.onmessage = (e) => renderResults(e.data);
// worker.js
self.onmessage = (e) => {
const result = expensiveCalculation(e.data);
self.postMessage(result);
};Workers can’t touch the DOM, and copying very large data between threads has a cost, so they suit self-contained computation best.
11. Audit Third-Party Scripts
Analytics, chat widgets, A/B testing tools, ads and tag managers often add more JavaScript than your own code. List every third-party script, remove the ones nobody uses, load the rest with async or after the page has loaded, and replace heavy embeds (such as video players or chat widgets) with a lightweight placeholder that loads the real thing on click.
Quick Checklist
| Problem | Fix |
|---|---|
| Scripts block rendering | defer, async or type="module" |
| Bundle is too big | Tree shaking, smaller libraries, remove unused code |
| Files are large over the network | Minify plus Brotli or gzip |
| Code for later features loads upfront | Dynamic import() and route-based splitting |
| Returning visitors re-download scripts | Hashed file names with long Cache-Control |
| Clicks feel laggy (poor INP) | Break up long tasks, debounce handlers, use Web Workers |
| Janky scrolling or animation | Avoid layout thrashing, passive listeners, requestAnimationFrame |
| Slow because of widgets and trackers | Audit and defer third-party scripts |
Conclusion
Faster JavaScript is mostly about doing less on the main thread: send fewer bytes, send them later when they aren’t needed yet, cache them so they’re sent once, and split the work that remains into pieces small enough that the page stays responsive. Measure with PageSpeed Insights and DevTools, fix the biggest problem first, and measure again.
FAQs
How can I make JavaScript load faster?
Load scripts with defer, async or type=”module” so they don’t block rendering, reduce bundle size with tree shaking and minification, enable Brotli or gzip compression, split code with dynamic import(), cache files with hashed names, and serve them from a CDN.
What is the difference between async and defer?
Both download the script without blocking HTML parsing. defer runs scripts in document order after parsing finishes; async runs each script as soon as it downloads, in any order. Use defer for your app code and async for independent scripts such as analytics.
What is a long task in JavaScript?
Any piece of work that keeps the browser’s main thread busy for more than 50 milliseconds. During a long task the page cannot respond to clicks, taps or key presses, which hurts Interaction to Next Paint (INP).
How do I find what is making my JavaScript slow?
Use PageSpeed Insights or Lighthouse for an overview, the Chrome DevTools Performance panel to find long tasks and the functions inside them, and the Coverage tab to find code that is loaded but never used.
Does minifying JavaScript improve performance?
Yes. It reduces file size, so files download and parse faster. Combined with Brotli or gzip compression on the server, it is one of the simplest wins.
What is INP and how does JavaScript affect it?
Interaction to Next Paint measures how quickly a page visibly responds to user interactions; 200 ms or less is good. Long-running JavaScript on the main thread is the most common cause of poor INP.
When should I use a Web Worker?
For CPU-heavy work that doesn’t need the DOM, such as parsing large files, data processing or image manipulation. Running it in a worker keeps the main thread free to respond to the user.








