Key takeaways
- JavaScript hurts speed mostly by occupying the browser’s main thread. Any task over 50 ms counts as a long task and delays input handling.
- The levers are: ship less (code splitting), run it later (
defer, dynamicimport()), run it in pieces (yielding), or run it elsewhere (web workers). - Profile first in Chrome DevTools or Lighthouse, and measure on a throttled device profile, not only your fast laptop.
- A backend load test does not measure any of this. Front-end JavaScript runs in the visitor’s browser, so you need page-speed measurements next to your load tests.
Why does JavaScript slow a page down?
Browsers run JavaScript, style calculation, layout and input handling on one main thread. While a script is running, that thread cannot respond to a click or paint a frame. web.dev defines it plainly: “Any task that takes longer than 50 milliseconds is a long task”, and if many long tasks pile up, “the user interface will feel unresponsive”.
That is why JavaScript cost is about time on the CPU more than about bytes on the wire. The same bundle costs more on a mid-range phone than on a developer laptop, which is why you should profile with CPU throttling or on a real device profile.
How does JavaScript affect Core Web Vitals and INP?
The metric that tracks this most directly is Interaction to Next Paint (INP). web.dev describes it as assessing a page’s overall responsiveness to user interactions, observing the latency of all clicks, taps and key presses during a visit. It replaced First Input Delay, which only measured the first interaction. The thresholds, at the 75th percentile of field page loads split by mobile and desktop, are 200 ms or less for good, up to 500 ms for needs improvement, and above 500 ms for poor.
JavaScript can also delay Largest Contentful Paint when render-blocking scripts hold back the first paint, and shift layout (CLS) when scripts inject content late. Long tasks during load are what the lab metric Total Blocking Time summarises, which is why Lighthouse reports it. For definitions and thresholds of each metric, see our Core Web Vitals glossary entry.
How do I find what is slow?
Measure before you change anything.
- Chrome DevTools Performance panel: record a load or an interaction and look for long tasks in the main-thread track.
- Lighthouse and PageSpeed Insights: lab results for a page, with field data where available.
- Real user monitoring: the only way to see INP as your visitors experience it, across their real devices.
Once one script or function dominates a long task, you can choose between deferring it, splitting it, yielding inside it or moving it to a worker.
How do defer and async change when scripts run?
Both download a classic script in parallel with HTML parsing. They differ in when it runs. MDN’s script element reference says an async script is “evaluated as soon as it is available”, which blocks rendering while it runs, with “no guarantee that scripts will run in any specific order”. A defer script runs after the document has been parsed but before DOMContentLoaded, and deferred scripts “will execute in the order in which they appear in the document”. Module scripts (type="module") are deferred by default.
The practical rule from MDN: use defer for scripts that depend on the DOM or on each other, and async for independent scripts that should run as soon as possible, such as an analytics snippet. Both attributes need a src; neither works on inline classic scripts.
How does code splitting reduce JavaScript cost?
Without splitting, “a single, large bundle of JavaScript will be shipped to your users as soon as they load the first page” (web.dev). Dynamic import() lets the bundler cut the code a route or feature needs into its own chunk, so the first download only carries what the first screen uses.
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor();
});
Webpack, Rollup and Vite all create separate chunks for dynamic imports. Splitting changes when code loads, not how fast it executes, so check in DevTools that the initial bundle really did shrink.
How do I break up a long task?
If one function runs longer than about 50 ms, yield to the main thread in the middle so the browser can handle input. The portable way is a promise around setTimeout, which web.dev suggests as the fallback when scheduler.yield() is missing:
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));
async function processAll(items) {
for (const item of items) {
processItem(item);
await yieldToMain();
}
}
scheduler.yield() does the same job and schedules the continuation as a prioritized task, so your work resumes ahead of other queued tasks. MDN marks it “Limited availability”, not Baseline, and web.dev’s support table lists Chrome, Edge and Firefox as supporting it and Safari as not (both read 2026-10-23). Feature-detect it with globalThis.scheduler?.yield and fall back to the setTimeout version.
I ran a demonstration of the effect in Node 23.11.1 (not a browser, so it shows the event loop rather than rendering): 200 steps of CPU work in a single loop, with a 5 ms timer ticking alongside. With no yielding, the longest gap between timer ticks was 213 ms; with await yieldToMain() after every step, 6 to 10 ms. The numbers depend on the machine and are only there to show the shape: the work takes about the same total time, but the thread stays available in between. The yieldToMain snippet above is the one I ran.
For work that is low priority, requestIdleCallback() queues a function for the browser’s idle periods. MDN warns that without a timeout option it may be seconds before the callback fires, and also marks it “Limited availability”, so detect it ('requestIdleCallback' in window) and keep a setTimeout fallback.
When should I use web workers?
For processor-intensive work, such as parsing a large file or heavy calculation. MDN says workers “can perform tasks without interfering with the user interface”. Two limits matter. A worker cannot touch the DOM directly; it must send a message back with postMessage(). And data passed between page and worker is copied through structured cloning, not shared, so shipping a large object back and forth has a cost. Transferable objects such as an ArrayBuffer move with a zero-copy operation, after which the sender can no longer use the buffer.
Does memoization help?
When a pure function is called repeatedly with the same arguments, caching the result avoids redoing the work. The same Node run measured one 5-million-iteration function: 6.3 to 6.9 ms on the first call and under a thousandth of a millisecond for a repeat call with the same argument, from a plain Map cache. That is a trivial function on one machine, so treat it as an illustration. Memoize things profiling shows are expensive and called often with repeated inputs, and watch memory: an unbounded cache is a leak.
Common mistakes
- Optimizing before profiling.
- Loading third-party scripts synchronously in the
<head>. - Using
asyncfor scripts that depend on each other, then debugging an order-dependent failure. - Testing only on a fast laptop with a warm cache.
- Binding many listeners on child nodes instead of delegating to a parent.
- Forgetting to remove listeners, which leaks memory in long-lived pages. Our memory leak guide covers finding them.
What does JavaScript performance have to do with load testing?
Less than the title of this blog suggests, and it is worth being clear about. A load test of your backend, whether in JMeter, k6 or LoadFocus, sends HTTP requests and measures how the server responds. It does not execute your page’s JavaScript, so it says nothing about long tasks, INP or hydration cost. Conversely, a clean Lighthouse score says nothing about how the server copes with 5,000 concurrent users.
Treat them as two halves of the same question. Load tests tell you whether the server stays fast under traffic; front-end measurements tell you whether the page stays responsive once the response arrives. For the second half, LoadFocus’s page speed monitoring describes scheduled Lighthouse audits and Core Web Vitals checks, with budgets and alerts when a metric regresses, from multiple locations. For background on the front-end side, see front-end performance testing and monitoring Core Web Vitals with alerts.
Frequently Asked Questions
What is JavaScript performance optimization?
It is reducing how much JavaScript the browser downloads, parses and runs, and when it runs it, so rendering and input handling are not blocked. Typical tools are code splitting, defer, yielding inside long tasks and moving heavy work to web workers.
Which is better, async or defer?
Neither is better in general. defer keeps document order and runs after parsing, so it suits scripts that depend on the DOM or on each other. async runs as soon as the file arrives, in no guaranteed order, so it suits independent scripts.
What counts as a long task?
Any task on the main thread that takes longer than 50 milliseconds, according to web.dev. The part beyond 50 ms is the blocking period.
Do web workers speed up my page?
They keep heavy computation off the main thread, so the page stays responsive. They do not make the computation itself faster, and passing large data to and from a worker is not free.
Does a load test measure my JavaScript performance?
No. A backend load test measures server and network behaviour. Front-end JavaScript runs in the browser, so measure it with Lighthouse, DevTools, or field data on Core Web Vitals.
Sources
- web.dev, “Optimize long tasks” (web.dev/articles/optimize-long-tasks, read October 2026)
- web.dev, “Interaction to Next Paint” (web.dev/articles/inp, read October 2026)
- web.dev, “Code splitting with React.lazy and Suspense” (web.dev/articles/code-splitting-suspense, read October 2026)
- MDN,
<script>element;Scheduler.yield();Window.requestIdleCallback(); “Using Web Workers” (developer.mozilla.org, read October 2026). I read MDN’s availability statements; its browser compatibility tables did not come through, so support is stated only as MDN’s and web.dev’s wording. - LoadFocus page speed monitoring (loadfocus.com/page-speed-monitoring, read October 2026)
- Demonstration script (Node 23.11.1, run October 2026): the
yieldToMainand memoization snippets above, usingsetInterval(..., 5)to record the longest gap between ticks.