Blog

Blog · Performance & Optimization

How to Speed Up JavaScript in 2026: 16 Performance Techniques

Make JavaScript fast — break up long tasks with scheduler.yield(), offload to Web Workers, avoid layout thrashing, debounce, use passive listeners and the right data structures — each with why it matters and a code example.

XCODX Team · 8 min read

JavaScript runs on a single main thread, and everything it does there — parsing, layout, your event handlers — competes for the same time. Slow JavaScript shows up as sluggish typing, janky scrolling and a poor Interaction to Next Paint (INP) score. Here are 16 JavaScript performance techniques for 2026, each with the reasoning and a code example, focused on keeping that main thread free.

1. Break up long tasks

Any task over 50ms blocks the main thread and delays user input, hurting INP. Split long loops and yield control back to the browser between chunks so it can handle clicks and paints. scheduler.yield() is the modern API (it resumes with priority), but it isn’t in Safari yet in 2026 — feature-detect and fall back to setTimeout / await new Promise.

async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    doWork(items[i]);
    if (i % 100 === 0) {
      // yield so input/paint can happen
      if (globalThis.scheduler?.yield) await scheduler.yield();
      else await new Promise((r) => setTimeout(r));
    }
  }
}

2. Move heavy computation to a Web Worker

CPU-heavy work (parsing large files, image processing, crunching data) blocks everything when run on the main thread. A Web Worker runs it on a separate thread, keeping the UI at 60fps. Communicate via postMessage; for large payloads use transferable objects (or SharedArrayBuffer) to avoid a copy.

// worker.js
self.onmessage = (e) => {
  const result = expensiveCrunch(e.data);
  self.postMessage(result);
};

// main thread — UI stays responsive
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
worker.onmessage = (e) => render(e.data);
worker.postMessage(bigInput);

3. Avoid layout thrashing (batch reads, then writes)

Reading a layout property (offsetWidth, getBoundingClientRect) after writing to the DOM forces the browser to recalculate layout synchronously. Doing that in a loop — read, write, read, write — is "layout thrashing" and is brutally slow. Batch all reads first, then all writes.

// ❌ forces a reflow every iteration
for (const el of els) el.style.width = el.offsetWidth + 10 + "px";

// ✅ read all, then write all
const widths = els.map((el) => el.offsetWidth);
els.forEach((el, i) => (el.style.width = widths[i] + 10 + "px"));

4. Debounce and throttle expensive handlers

Events like input, resize and scroll fire rapidly; running an expensive handler on every one wastes work and janks the page. Debounce when you only care about the final value (search-as-you-type), throttle when you want a steady rate (scroll position). Both cap how often the expensive work runs.

function debounce(fn, ms) {
  let t;
  return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
searchInput.addEventListener("input", debounce(runSearch, 250));

5. Use passive event listeners for scroll and touch

By default the browser must wait to see whether a touchstart/wheel handler calls preventDefault before it can scroll — adding input latency. Marking the listener { passive: true } promises you won’t block scrolling, so the browser scrolls immediately. Use it for any scroll/touch listener that doesn’t prevent default.

el.addEventListener("touchstart", onTouch, { passive: true });
window.addEventListener("wheel", onWheel, { passive: true });

6. Choose the right data structure (Map/Set over arrays)

Repeated array.includes() or array.find() lookups are O(n) each — inside a loop that’s O(n²) and gets slow fast. A Set (membership) or Map (keyed lookup) gives O(1) access. For thousands of items this is often a 10–100x difference.

// ❌ O(n) lookup inside a loop → O(n²)
const dupes = a.filter((x) => b.includes(x));

// ✅ O(1) lookup with a Set → O(n)
const setB = new Set(b);
const dupes = a.filter((x) => setB.has(x));

7. Batch DOM updates with a DocumentFragment

Appending nodes one at a time triggers layout/paint work repeatedly. Build the nodes in a DocumentFragment (off-document) and insert once, so the browser does a single layout pass. Even better for big changes, build an HTML string and set innerHTML once.

const frag = document.createDocumentFragment();
for (const item of items) {
  const li = document.createElement("li");
  li.textContent = item.name;
  frag.append(li);
}
list.append(frag);   // one insertion, one layout

8. Load scripts with defer or async

A plain <script> blocks HTML parsing while it downloads and executes. defer downloads in parallel and runs after parsing, in order — the right default for app scripts. async runs as soon as it downloads (order not guaranteed) — good for independent third-party tags. Never block parsing with a synchronous script in <head>.

<!-- app code: parse-blocking-free, ordered -->
<script src="/app.js" defer></script>
<!-- independent third-party: run ASAP -->
<script src="https://a.example/analytics.js" async></script>

9. Lazy-load code with dynamic import()

Don’t pay for code the user may never use. A dynamic import() splits a module into its own chunk that loads on demand — when a modal opens, a route activates, or a feature is used — cutting the initial bundle and main-thread parse time.

button.addEventListener("click", async () => {
  const { openEditor } = await import("./heavy-editor.js");
  openEditor();
});

10. Prevent memory leaks

Leaked listeners, timers and detached DOM references grow memory until the tab is sluggish or crashes — common in SPAs that mount/unmount a lot. Remove listeners and clear intervals on teardown, and use WeakMap/WeakRef for caches keyed by objects so entries can be garbage-collected. AbortController cleans up many listeners at once.

const ac = new AbortController();
el.addEventListener("scroll", onScroll, { signal: ac.signal });
// teardown removes every listener registered with this signal
ac.abort();

11. Use requestAnimationFrame for visual updates

Driving animations with setTimeout/setInterval desyncs from the display refresh, causing dropped or doubled frames. requestAnimationFrame runs your callback right before the next paint, aligned to the refresh rate, and pauses in background tabs. Do all visual DOM writes inside it.

function tick() {
  el.style.transform = `translateX(${x}px)`;
  if (running) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

12. Defer low-priority work with requestIdleCallback

Non-urgent work — analytics, prefetching, warming caches — can wait until the browser is idle instead of competing with rendering. requestIdleCallback schedules it in spare time and gives you a deadline. It’s not in Safari, so feature-detect and fall back to setTimeout. Never do latency-critical work here.

const ric = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
ric((deadline) => {
  while (deadline.timeRemaining() > 0 && queue.length) process(queue.pop());
});

13. Memoize and cache expensive results

Recomputing a pure result for the same input is wasted work. Cache results keyed by input so repeat calls are instant. Keep memoization for genuinely expensive, frequently-repeated pure functions, and bound the cache (or key it with a Map/WeakMap) so it doesn’t grow forever.

function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (cache.has(arg)) return cache.get(arg);
    const v = fn(arg); cache.set(arg, v); return v;
  };
}
const slugify = memoize(expensiveSlugify);

14. Prefer efficient, non-copying array work

Chaining map().filter().reduce() over a large array allocates a new array at each step; for hot paths, a single loop avoids the intermediate copies and is markedly faster. Also avoid spreading big arrays repeatedly ([...acc, x] inside a reduce is O(n²)). Readability first — optimize the measured hot paths.

// ❌ O(n²): builds a new array every iteration
const out = items.reduce((acc, x) => [...acc, transform(x)], []);

// ✅ O(n): mutate a local, or use one map()
const out = [];
for (const x of items) out.push(transform(x));

15. Ship less JavaScript

The fastest code is the code you never send. Smaller bundles mean less to download, parse, compile and execute on the main thread — the parse/compile cost alone is significant on mid-range phones. Audit dependencies, tree-shake, code-split, and drop polyfills for browsers you no longer support.

# find what's big, then cut it
npx source-map-explorer dist/assets/*.js
npx knip            # unused files, deps and exports

16. Profile before optimizing

Intuition about JavaScript performance is often wrong. Record the real interaction in the DevTools Performance panel, find the long tasks and their call stacks, and fix the actual bottleneck. Use performance.mark/measure to time specific operations, and validate INP in the field with the web-vitals library — lab numbers can mislead.

performance.mark("filter:start");
const result = filterBigList(data, query);
performance.mark("filter:end");
performance.measure("filter", "filter:start", "filter:end");
console.log(performance.getEntriesByName("filter")[0].duration);

JavaScript performance checklist

  • Break tasks over 50ms into chunks; yield with scheduler.yield() (feature-detected).
  • Offload CPU-heavy work to a Web Worker.
  • Batch DOM reads then writes to avoid layout thrashing; use a DocumentFragment.
  • Debounce/throttle rapid handlers; mark scroll/touch listeners passive.
  • Use Map/Set for lookups; avoid O(n²) patterns and needless array copies.
  • Load scripts with defer/async; lazy-load with dynamic import().
  • Clean up listeners/timers (AbortController); use requestAnimationFrame for visuals.
  • Ship less JS; profile with the Performance panel and validate INP in the field.

Frequently asked questions

What is the biggest cause of slow JavaScript?
Long tasks blocking the main thread. Any single task over 50ms delays user input and hurts Interaction to Next Paint (INP). The fixes are to break long work into small chunks that yield to the browser, and to move CPU-heavy work off the main thread into a Web Worker.
How do I improve INP with JavaScript?
Keep the main thread free around interactions: break up long tasks (yield with scheduler.yield(), feature-detected for Safari), debounce expensive input handlers, avoid layout thrashing, and defer non-urgent work with requestIdleCallback. Then measure INP with the web-vitals library on real users.
When should I use a Web Worker?
When you have CPU-heavy work — parsing large files, image or data processing, heavy computation — that would otherwise block the UI. Workers run on a separate thread so the main thread stays at 60fps. For large data, use transferable objects to avoid copying the payload.
Is scheduler.yield() supported everywhere?
Not yet. As of 2026 scheduler.yield() is available in Chromium browsers but not Safari, so feature-detect it and fall back to setTimeout or await new Promise(r => setTimeout(r)). It’s preferred over plain setTimeout because it resumes your task with priority rather than going to the back of the queue.

Test and profile JavaScript instantly in XCODX Studio — run it in the browser with npm packages, no setup. See also our JavaScript best practices, How to Improve Core Web Vitals and How to Reduce Bundle Size.