Scientyfic World

How to Speed Up JavaScript: 11 Proven Techniques (Tested)

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...

Share:

Get an AI summary of this article

post banner with Javascript logo and a vector art of a girl sitting Infront of a computer

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

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>
AttributeBlocks parsing?Execution orderUse for
noneYesIn order, immediatelyRarely needed today
deferNoIn document order, after parsingYour app code
asyncNoWhenever each one finishes downloadingIndependent scripts like analytics
type="module"No (deferred)In order, after parsingModern 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));
BuildSizeGzipped
A: lodash, not minified237,677 bytes42,594 bytes
A: lodash, minified73,924 bytes26,725 bytes
B: lodash-es, not minified10,912 bytes2,799 bytes
B: lodash-es, minified3,531 bytes1,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, immutable

Returning 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:

ApproachLongest taskClick waitedTotal work time
One loop523–548 ms (one long task)433–451 ms523–549 ms
Chunks with setTimeout yieldingNo long tasks6–7 ms1,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

ProblemFix
Scripts block renderingdefer, async or type="module"
Bundle is too bigTree shaking, smaller libraries, remove unused code
Files are large over the networkMinify plus Brotli or gzip
Code for later features loads upfrontDynamic import() and route-based splitting
Returning visitors re-download scriptsHashed file names with long Cache-Control
Clicks feel laggy (poor INP)Break up long tasks, debounce handlers, use Web Workers
Janky scrolling or animationAvoid layout thrashing, passive listeners, requestAnimationFrame
Slow because of widgets and trackersAudit 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts