Page Speed Optimization

How to reduce JavaScript execution time and break up long tasks

A smaller download helps, but a responsive page also needs shorter tasks and less startup work.

A long main-thread block divided into smaller blocks with gaps between them
Illustration: shorter tasks create opportunities for input and rendering.

Reduce JavaScript execution time by identifying expensive functions, removing unnecessary work and loading features only when they are needed. If necessary work still occupies the main thread for too long, split it into separate tasks or move suitable computation to a web worker. Download size, total CPU time and the longest uninterrupted task are different problems, so measure all three before choosing a fix.

Record the slow interaction before changing the bundle

Start with a production build and a repeatable action: opening navigation, filtering products or expanding a report. Record the initial page load separately from the interaction in Chrome DevTools' Performance panel. Keep the device, CPU throttling, network conditions and consent state consistent. Extensions and development instrumentation can change a trace, so use a clean browser profile and record the environment with the result.

Select the period when the interface stops responding. Inspect the Main track, then use Bottom-up to find functions with substantial self time and Call tree to understand who called them. Self time belongs to the function itself; total time includes descendants. A small wrapper with a large total is a route to the expensive work, not necessarily the function to rewrite. Source maps help connect bundled code to the module you maintain.

A long task lasts more than 50 milliseconds, according to web.dev's task guidance. That boundary identifies blocking work; it is not a performance budget that every task should approach. Several shorter tasks can still leave a page unpleasant to use, especially when rendering and third-party scripts compete for time. Inspect the interaction itself rather than counting red markers and declaring success.

Lighthouse's JavaScript diagnostics and treemap can point toward expensive resources and large dependencies. They do not prove that the largest file caused the observed pause. Compare resource size with execution in the trace, and consult the guide to improving INP when the delay spans input handling, processing and the next paint.

Separate removal, postponement and scheduling

Choose the intervention that matches the evidence. Compression reduces transferred bytes, but the browser still receives the decompressed program. Minification is useful delivery hygiene, not a substitute for reducing the work the program performs.

These changes solve different problems and should be evaluated separately.

Evidence First change to investigate What it does not guarantee
Unused feature code on the initial route Remove it or create a separate chunk A faster first use of that feature
Repeated initialization or duplicate listeners Eliminate repeated work Smaller network transfers
Long computation independent of the DOM Worker or a better algorithm Lower total CPU consumption
Necessary sequence blocks visual feedback Yield between bounded steps Faster completion of the entire sequence
Large style or layout work triggered by JavaScript Reduce DOM changes and layout dependencies A fix from changing script attributes alone

Write a hypothesis before editing: which function should run less often, which chunk should disappear from the startup waterfall, or where a rendering opportunity should appear. That makes the next trace an explanation of the change rather than just another score to compare.

Ship only the code the current view needs

Inspect the production bundle with the bundler's analyzer or the Lighthouse treemap. Look for duplicate package versions, unused locale data, large libraries imported for a small helper and features included on every page. Check why the dependency entered the graph before replacing it. A smaller replacement can add maintenance obligations or change behavior that matters to users.

Tree shaking removes statically identifiable unused exports when the build supports it. It works best with ES modules and accurate side-effect declarations. Do not mark every package as side-effect-free to force a smaller result. A module that installs a polyfill, registers a component or imports essential CSS may need to execute even when none of its exports are referenced. Production-mode output, not the source import statement, is the evidence that removal happened.

Coverage is another clue, with an important limit: code unused in a recording may be needed after login, on another route or when a dialog opens. Exercise those paths before deleting it. Keep error handling and accessibility behavior in the audit; rare use does not make a feature unnecessary. Prefer removing a genuinely abandoned feature over fragile transformations of code still required for normal operation.

Load an optional feature with dynamic import

A dynamic import creates a loading boundary when the build system supports code splitting. Place that boundary around a feature users can request independently, such as an export preview. Splitting every tiny function creates request and coordination overhead; splitting an entire route may be too coarse for a rarely used component inside it.

The following example uses three files served over HTTP, with a module loaded only after activation. The button's visible label, progress text and error text should be localized by the application.

html
<!-- index.html -->
<button id="preview" type="button">Preview export</button>
<p id="status" role="status"></p>
<pre id="output"></pre>
<script type="module" src="/main.js"></script>

The entry module restores the control even when importing fails.

js
// main.js
const button = document.querySelector('#preview');
const status = document.querySelector('#status');
const output = document.querySelector('#output');
button.addEventListener('click', async () => {
  button.disabled = true;
  status.textContent = 'Loading preview';
  try {
    const { renderPreview } = await import('./preview.js');
    renderPreview(output);
    status.textContent = 'Preview ready';
  } catch {
    status.textContent = 'Preview unavailable. Try again.';
  } finally {
    button.disabled = false;
  }
});

The separately loaded module supplies the optional feature.

js
// preview.js
export function renderPreview(element) {
  element.textContent = JSON.stringify({ format: 'csv', ready: true }, null, 2);
}

This is a functional loading example, not an expensive workload or a measured optimization. Real feature code may still perform costly initialization when imported. Test its first activation on a cold cache as well as subsequent activations. If that first use becomes slow, improve the feature or cautiously prefetch it when user intent is clear; do not simply move a startup problem onto the first click.

Give the browser real opportunities to respond

Calling several small functions synchronously still produces one uninterrupted task. Likewise, awaiting an already resolved promise does not reliably create a rendering opportunity: promise continuations are microtasks. Use a mechanism that schedules continuation in a future task when the operation can safely pause.

This helper prefers scheduler.yield() where available and otherwise uses a timer, while the loop keeps each batch bounded.

js
function yieldToBrowser() {
  if (globalThis.scheduler?.yield) return globalThis.scheduler.yield();
  return new Promise(resolve => setTimeout(resolve, 0));
}

export async function normalizeRows(rows, batchSize = 100) {
  if (!Number.isInteger(batchSize) || batchSize < 1) {
    throw new RangeError('batchSize must be a positive integer');
  }
  const normalized = [];
  for (let start = 0; start < rows.length; start += batchSize) {
    const end = Math.min(start + batchSize, rows.length);
    for (let index = start; index < end; index++) {
      normalized.push(String(rows[index]).trim());
    }
    if (end < rows.length) await yieldToBrowser();
  }
  return normalized;
}

The batch size is an illustrative application setting, not a universal safe threshold. A single expensive item can still create a long task. Tune using the work and devices that matter, and consider cancellation if the user changes the input while processing continues. The timer fallback lacks the scheduling priority behavior of the native API, so test both paths. See the official long-task guidance for the scheduling model.

Yielding can improve responsiveness without reducing total execution time. It can even add overhead. Preserve operations that must remain atomic, and do not show a successful save before the save has completed. Feedback should describe the real state, such as processing, rather than conceal unfinished work.

Move independent computation to a web worker

Workers execute JavaScript outside the window's main thread but cannot directly manipulate its DOM. They suit independent parsing, data transformation and computation. They do not automatically help a task dominated by inserting elements, measuring layout or painting. Moving a tiny operation to a worker can cost more than performing it locally because worker startup and messaging also require resources.

This small module worker demonstrates the boundary; its sample data is deliberately too small to imply a performance gain.

js
// totals.worker.js
self.addEventListener('message', event => {
  const total = event.data.reduce((sum, value) => sum + value, 0);
  self.postMessage(total);
});

The caller handles the result on the main thread and releases the worker afterward.

js
// totals.js, loaded as a module
const worker = new Worker(new URL('./totals.worker.js', import.meta.url), {
  type: 'module'
});
worker.addEventListener('message', event => {
  console.log('Total:', event.data);
  worker.terminate();
});
worker.addEventListener('error', () => worker.terminate());
worker.postMessage([2, 4, 6]);

For a real workflow, define input validation, error reporting and cancellation. Reuse a worker when repeated jobs justify its lifetime. Large messages may require copying; transferable buffers can avoid some copying but transfer ownership, which changes what the sender can do afterward. Measure end-to-end response time, main-thread availability and memory use before deciding that off-thread work is an improvement.

Defer startup work without hiding a later bottleneck

A deferred script still executes on the main thread. async changes loading and execution timing, while defer also preserves ordered execution for classic external scripts after parsing. Neither attribute divides an expensive function into smaller tasks. Use the render-blocking resources guide for loading dependencies, then return to the trace to assess execution.

Reserve idle scheduling for optional work. requestIdleCallback() has limited availability, and the browser may postpone its callback. Feature-detect it, choose a fallback and decide whether a timeout is appropriate. Required navigation, form validation and essential feedback should not depend on idle time becoming available. A timeout also does not make a large callback cheap; the callback must still perform bounded work.

Server-rendered HTML can appear before a framework attaches interactive behavior. Hydration may then consume substantial CPU, so an early visual display does not prove that controls respond promptly. Limit the client component surface, avoid duplicate initialization and use framework-supported selective or deferred hydration only where behavior remains correct. Test clicks during startup, keyboard access, focus and the first use of each deferred region.

Third-party code shares the same opportunity to interrupt your work. Review third-party script costs and remove duplicate tags before trying to compensate with elaborate scheduling. Record each script's purpose and activation condition so later additions do not silently restore the original bottleneck.

Trace repeated work and unnecessary rendering

An expensive algorithm is not the only reason a page spends too much time in JavaScript. The same inexpensive operation may run whenever unrelated state changes, whenever a scroll event fires or whenever a component mounts again. Count invocations in the trace and connect them to the user action. Fixing an incorrect subscription or a duplicated listener can be more durable than rewriting the callback itself.

For an interactive list, separate data transformation from display updates. Recompute only when the inputs actually change, and avoid recreating the entire list when a small region needs updating. Memoization is useful only when the avoided work is worth its memory and invalidation complexity. A cached result with the wrong dependencies can display outdated information, so correctness tests belong beside any performance comparison.

Look for sequences that write a style and then read geometry repeatedly. The reads may force the browser to calculate layout before JavaScript can continue. Where the feature permits it, collect the required measurements before applying the batch of writes. Do not assume that a fast JavaScript function is harmless if it repeatedly provokes expensive style and layout work elsewhere in the trace.

Finally, remove intervals, observers and event subscriptions when the component or page region no longer needs them. A trace taken immediately after load may miss work that accumulates during navigation. Repeat the journey several times without reloading and check whether processing grows. This is a diagnostic scenario, not a claim that any particular framework leaks resources.

Verify the result in the same user journey

Repeat the original trace with matching conditions, then exercise a cold feature load, a repeat visit and a slower device. Compare the longest tasks and the interaction's visible response, not only total bundle bytes. A shorter startup can coexist with a worse first interaction, and a quicker callback can still trigger expensive rendering afterward.

Use the performance checklist to record the change, evidence and rollback path. Put broader follow-up work in the page speed optimization collection, then select the next expensive function from the new trace. Keep the change only when the original journey improves and its loading, error and accessibility paths still work.

Related guides