JavaScript performance is about less work, not fewer lines
Performance work is choosing which computations not to run, measuring Core Web Vitals honestly, and fixing the hottest paths instead of shrinking source line counts for their own sake.
This article is published in English.
Sources: JavaScript Performance Isn’t About Writing Fewer Lines
The fastest JavaScript is often JavaScript you never execute
Performance work is frequently framed as writing tighter loops. In browsers, the larger wins usually come from doing less work: fewer handlers firing, fewer DOM mutations, fewer network round trips, less main-thread time. Consider a live search field that re-filters on every keystroke:
input.addEventListener("input", () => {
renderResults(filterUsers(input.value));
});
Typing a name produces a cascade of intermediate queries:
m
ma
mah
maha
mahad
Most of those intermediate results are discarded. Debouncing collapses the stream so only a settled query runs:
const debounce = (fn, delay = 250) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, delay);
};
};
const search = debounce((query) => {
renderResults(filterUsers(query));
});
The function body did not become cleverer; the schedule of work changed, which is usually where browser performance is won or lost. That pattern—avoid, defer, coalesce—beats micro-optimizing a hot function that should not run so often.
Intermediate queries are not free. Each keystroke can allocate arrays, walk large lists, update React or DOM nodes, and schedule paints. On mid-range phones those costs stack into visible jank. Debouncing is only one coalescing tool; throttling scroll handlers, coalescing resize observers, and skipping work when a tab is hidden are siblings of the same idea: run when the result will still matter.
Don’t optimize the function. Optimize the work.
Mapping every item through an expensive transform looks innocent:
items.map(item => transform(item));
Ask why the transform runs at all, for which screen, and how often results change. If the same user is formatted repeatedly, memoize:
const cache = new Map();
function getFormattedUser(id, user) {
if (cache.has(id)) {
return cache.get(id);
}
const result = formatUser(user);
cache.set(id, result);
return result;
}
Caching identical inputs removes repeated CPU without rewriting the transform itself. The same mindset applies to sorting, filtering, and deriving view models: compute when inputs change, not on every render tick.
Memoization is not a free lunch. Unbounded maps grow until memory pressure causes its own jank. Key by stable identifiers, bound cache size, and invalidate when source records change. Prefer deriving view models once per state transition in a store rather than recomputing deep trees in every leaf component.
Your JavaScript can be fast while your UI is slow
Appending into the DOM inside a loop forces repeated layout work:
for (const item of items) {
container.innerHTML += createCard(item);
}
Building a string once and assigning once is cheaper:
const html = items
.map(createCard)
.join("");
container.innerHTML = html;
DOM work has consequences beyond the JavaScript statement that caused it. Style and layout recalculation can dominate frame time even when the script log looks “fast.”
Document fragments and offline DOM construction help when you must create many nodes. Frameworks batch updates for you when you let them; fighting the framework with direct innerHTML loops in hot paths reintroduces the cost those libraries exist to hide. Prefer keyed list updates so unchanged rows are not destroyed and recreated.
Layout can become more expensive than your function
Reading layout properties forces the browser to compute geometry:
const height = element.offsetHeight;
Writing styles and reading again creates thrashing:
element.style.height = `${height + 20}px`;
const newHeight = element.offsetHeight;
Batch reads, then batch writes. Avoid interleaving measurement and mutation in hot paths such as scroll handlers and animations.
Forced synchronous layout is a common footgun in custom tooltips, sticky headers, and canvas-aligned HTML. If a library must measure, measure once per frame and cache. requestAnimationFrame is a better schedule than raw scroll listeners that read geometry on every event.
Don’t make the main thread your personal to-do list
Heavy transforms on large arrays block input and paint:
function processLargeDataset(data) {
return data.map(expensiveTransformation);
}
Ask whether the work must live on the main thread. Workers move CPU-bound jobs off the UI:
const worker = new Worker("processor.js");
worker.postMessage(largeDataset);
worker.onmessage = (event) => {
updateUI(event.data);
};
Keep messaging costs in mind; transfer or structured-clone large payloads deliberately.
Workers change ownership of CPU, not of product complexity. Serialization can erase gains for tiny jobs. Use them for parsing, encryption, image work, or large aggregations—not for microtasks that finish faster than a postMessage round trip. SharedArrayBuffer and transferable objects help when payloads are huge and security headers allow them.
Lazy loading is a performance feature
Don’t load something until you need it. Dynamic import defers code for rare routes:
async function openAnalytics() {
const { Analytics } = await import("./analytics.js");
const analytics = new Analytics();
analytics.open();
}
Users who never open analytics never download that module. Pair with route-level code splitting so initial navigation stays lean.
Route-based splitting should follow user journeys, not folder boundaries alone. Prefetch on hover or idle for likely next routes so the benefit of lazy loading does not become a click-time wait. Watch for duplicate dependencies across chunks that inflate totals after splitting.
Bundle size isn’t the whole story
Smaller bundles help, but a tiny bundle that still blocks the main thread or fetches waterfalls can feel worse than a slightly larger bundle that streams critical UI first. Measure time-to-interactive and interaction latency, not only kilobytes.
Network performance is JavaScript performance too
Sequential awaits serialize latency:
const user = await fetch("/api/user");
const posts = await fetch("/api/posts");
const notifications = await fetch("/api/notifications");
Independent requests should overlap:
const [user, posts, notifications] = await Promise.all([
fetch("/api/user"),
fetch("/api/posts"),
fetch("/api/notifications")
]);
Waterfalls hide inside async functions as easily as inside poorly ordered script tags.
The most expensive request is the one you didn’t need
Initial screens often need a small core set of data while the app also eagerly requests secondary panels. Prefer loading the primary dashboard first, then deferring the rest:
await loadDashboard();
requestIdleCallback(() => {
loadSecondaryData();
});
Not every task deserves the same priority. Idle callbacks, lower-priority fetches, and explicit user gestures are tools for matching work to urgency.
Caching can be more important than micro-optimizations
Caches exist at HTTP, service worker, memory, and derived-state layers. A correct cache key and invalidation policy can remove more latency than rewriting a sort. Document freshness rules so stale UI does not become a silent product bug.
HTTP caching headers and ETags reduce repeat downloads; in-memory caches reduce repeat JSON parsing; UI stores reduce repeat derived computations. Each layer needs an explicit owner for invalidation. Without that ownership, teams either over-fetch “to be safe” or show stale badges that erode trust.
Virtualization changes the problem completely
Rendering thousands of rows eagerly fails:
rows.forEach(renderRow);
Virtual lists render only what is visible. Complexity moves from “make each row cheaper” to “don’t create offscreen rows,” which is usually the right trade.
Windowing libraries trade simplicity for scroll correctness: variable row heights, focus management, and sticky columns need care. Still, for large datasets they outperform heroic CSS tricks. Combine with server pagination so the client never holds millions of records just to virtualize them.
Performance tools beat guessing
When something feels slow, measure it. Chrome DevTools surfaces network waterfalls, main-thread long tasks, layout thrashing, and memory growth. Guessing leads to polishing the wrong function.
A simple performance investigation
Walk a checklist instead of jumping to premature micro-opts:
- Is the network slow?
- Is too much JavaScript being downloaded?
- Is JavaScript blocking the main thread?
- Is rendering expensive?
- Are we doing the same work repeatedly?
- Is memory growing?
- Does the problem happen only at scale?
Answer with traces, not folklore.
The performance trap seen most often
Teams spend weeks optimizing code that isn’t responsible for the problem—shaving microseconds off a transform while a four-second waterfall or unbounded list render owns the experience. Confirm the bottleneck first.
Modern JavaScript gives better tools
Use platform features that reduce custom machinery:
structuredClone(data);
const result = await Promise.allSettled(tasks);
const module = await import("./feature.js");
const controller = new AbortController();
fetch("/api/search", {
signal: controller.signal
});
Search UX often needs abortable in-flight requests so stale responses do not win races. One robust pattern:
javascript
javascript performance
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
return await response.json();
} catch (error) {
if (error.name !== "AbortError") {
throw error;
}
}
}
AbortController, dynamic import, and Promise.allSettled replace many hand-rolled queues and flags.
Performance has a product dimension
A 100ms win is not automatically valuable; location matters. Halving a 2ms helper rarely changes perception. Cutting a four-second first load to two seconds does. Instant typing feedback can matter more than a slightly faster background job. Smooth scrolling can matter more than shaving API transform time. Performance is the experience the system creates, not the elegance of a microbenchmark.
Stop chasing numbers that don’t represent users
Synthetic benches help, but a fast laptop on a LAN with tiny fixtures is not production. Test representative devices, networks, data sizes, and interaction patterns—or you optimize a fictional app.
Lab versus field metrics both matter. Lighthouse catches regressions in CI; real-user monitoring catches device and geography skew. When they disagree, trust the field for prioritization and the lab for reproduction. Performance budgets on LCP, INP, and long tasks keep debates concrete.
The performance mindset
Prefer this sequence of questions: What work can I avoid? What can happen later? What can happen less often? What can happen elsewhere? What actually matters to the user? Answers may be debounce, cache, code split, virtualize, worker, API change, query fix—or deleting an unused feature.
Final thoughts
The deepest JavaScript performance lesson is not a single API. Fast code is not always code that executes quickly. Sometimes it never runs, runs later, runs once instead of fifty times, leaves the main thread, or stops asking other systems for needless work. Stop chasing “fewer lines” as a proxy for speed. Chase less unnecessary work in the places users feel.
Deleting unused surfaces is a performance feature. Every dormant chart library, autoplaying media hook, or speculative prefetch competes for bandwidth and main-thread time. Product and engineering should review which experiences still earn their cost. The fastest JavaScript remains the JavaScript you never download, never parse, and never run.
Debouncing in the wild: search, autosave, and resize
Live search is the teaching example, but the same schedule discipline applies elsewhere. Autosave that writes on every keystroke can stampede a backend; waiting until typing pauses is kinder and often identical from the user’s point of view. Window resize and scroll handlers that recompute layout on every event are classic jank sources; throttling to animation frames or a fixed interval keeps the main thread responsive. The shared idea is not “add lodash”—it is to separate intent (the user wants filtered results) from schedule (how often we afford to compute).
When you debounce, choose delays from product feel: 150–300 ms for search often feels instant after the pause; longer delays suit expensive analytics beacons. Cancel in-flight work when a newer query supersedes an older one so late responses cannot paint stale results. That cancel step is as important as the timer.
Caching strategies beyond a Map
An in-memory Map keyed by id is only one layer. HTTP caches respect Cache-Control and ETags so repeat navigations skip transfers. Service workers can cache shell assets and API GETs with explicit purge rules. Application stores can keep normalized entities so list and detail views share one record. Derived view-models—sorted, filtered projections—should recompute when inputs change, not on every unrelated parent render.
Invalidation is the hard part. Time-to-live is simple and blunt. Event-based invalidation (“user updated profile”) is precise when you control writers. Hybrid approaches use short TTL plus explicit busts on mutations. Document which layer owns which data so two caches do not disagree silently.
DOM batching and framework cooperation
Direct innerHTML += loops are an anti-pattern even outside frameworks. Document fragments, replaceChildren, or building a string once all reduce layout thrash. Inside React, Vue, or Svelte, prefer state updates that let the library reconcile keyed lists. Measuring offsetHeight inside render paths reintroduces forced layout; move measurements into layout effects or observers scheduled after paint.
CSS containment and content-visibility can limit work for offscreen regions when virtualization is too heavy. They are not magic: test on the browsers you support and watch for accessibility impacts when content is skipped.
Main-thread budgeting
Aim to keep event handlers under a frame budget—roughly 16 ms at 60 Hz, less if you share the frame with other work. Break large transforms into chunks with requestIdleCallback or async generators yielding periodically. For parsing multi-megabyte JSON, prefer streaming parsers or workers. Profile with the Performance panel’s long-task track; if a task exceeds 50 ms, users on slower devices will feel it.
Workers deserve clear ownership: CPU-bound pure functions yes; anything that needs DOM no. Structured clone cost grows with object graph size; transfer ArrayBuffers when possible. Startup cost of spawning workers matters for one-off tiny jobs—sometimes a chunked main-thread approach wins.
Network waterfalls and priority
await in sequence is the silent latency multiplier. Independent fetches belong in Promise.all. Dependent fetches should pass only the required ids forward, not entire payloads, to shrink later responses. HTTP/2 multiplexing helps but does not erase server think time; reduce round trips with APIs that return what the screen needs in one shot when feasible.
Priority matters: hero content first, secondary widgets later. fetch priority hints, link preload, and idle callbacks express that intent. Prefetch on hover for likely navigations if the cost is bounded. Avoid speculative prefetch of large authenticated datasets the user may never open.
Virtualization and data shape
Virtual lists assume roughly uniform row heights or careful measurement caches. Variable-height rows need more bookkeeping but still beat rendering ten thousand DOM nodes. Pair client virtualization with server pagination or cursor APIs so memory stays flat. Infinite scroll should pause when the tab is hidden and should announce loading states for assistive tech.
Measurement culture
Lab tools catch regressions in CI; field tools catch device reality. When INP is poor, inspect event handlers and long tasks, not only network. When LCP is poor, inspect hero image prioritization and server TTFB. When CLS is poor, reserve space for late content. Tie each metric to an owner and a budget so “performance work” is not endless polishing of irrelevant helpers.
Synthetic microbenchmarks of a pure function rarely predict user experience. Prefer traces of real interactions: type into search, open a modal, scroll a dense table. Save traces as artifacts when filing bugs so debates stay concrete.
Product trade-offs
Not every millisecond is equal. Instant feedback on typing or dragging is worth more than shaving a background report that runs once a week. Sometimes the best optimization is removing a feature that costs bandwidth and attention for little value. Performance is a product decision expressed in engineering work.
Putting the mindset on a sticky note
Avoid work. Defer work. Coalesce work. Move work off the critical path. Measure what users feel. Those five lines beat a hundred premature micro-opts. JavaScript performance is less about writing fewer lines and more about ensuring the lines you keep earn their place on the user’s device.