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.
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/Setfor lookups; avoid O(n²) patterns and needless array copies. - Load scripts with
defer/async; lazy-load with dynamicimport(). - Clean up listeners/timers (
AbortController); userequestAnimationFramefor 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?
How do I improve INP with JavaScript?
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?
Is scheduler.yield() supported everywhere?
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.