JavaScript performance tricks that actually move the needle
Storytime: I once inherited a codebase where a simple list render took over 800ms. The culprit was a loop touching the DOM 200 times instead of once. A quick refactor of only four lines dropped that time to 12ms. I have been a little obsessed with JS performance ever since.
The good news is that you don’t need to understand V8 internals to write fast JavaScript. A handful of patterns cover most of the performance gains you’ll ever need. Let’s look at the ones that matter.
Stop touching the DOM so much
Every time you modify the DOM, the browser recalculates styles, reruns layout, and repaints. Doing this in a loop triggers that entire pipeline on every iteration.
To fix this, batch your changes off-screen first, then commit them once.
// ❌ Triggers a reflow for every single item
const container = document.getElementById("user-list");
users.forEach((user) => {
const li = document.createElement("li");
li.textContent = user.name;
container.appendChild(li); // reflow, reflow, reflow...
});
// ✅ One reflow, no matter how many items
const fragment = document.createDocumentFragment();
users.forEach((user) => {
const li = document.createElement("li");
li.textContent = user.name;
fragment.appendChild(li);
});
container.appendChild(fragment); // single reflow here The same idea applies to style changes. Instead of setting individual properties, which each trigger their own recalculation, swap a CSS class instead:
// ❌ Multiple style changes = multiple reflows
element.style.width = "200px";
element.style.height = "200px";
element.style.backgroundColor = "blue";
// ✅ One class change = one reflow
element.classList.add("highlighted-box"); Your CSS already lives in a stylesheet. Let the browser do its job.
Write loops that know when to stop
Loops are where I see the most avoidable waste. The classic example is iterating an entire array looking for one item, even after you’ve already found it.
// ❌ Checks all 10,000 users even after finding the one you want
let target;
users.forEach((user) => {
if (user.id === 42) target = user;
});
// ✅ Stops the moment it finds a match
const target = users.find((user) => user.id === 42); find(), some(), and findIndex() all short-circuit, meaning they stop iterating the moment the condition is met. Reach for them before writing a manual loop.
For hot loops over large datasets, cache the array length:
const len = items.length;
for (let i = 0; i < len; i++) {
// avoids re-evaluating items.length on every iteration
} This may seem like a minor detail, but in tight loops processing thousands of items, it adds up.
Parallel async operations with Promise.all
This optimization is often overlooked. If you have multiple independent async operations, awaiting them sequentially leaves performance on the table.
// ❌ Sequential: 6 seconds total
async function loadPage() {
const users = await fetchUsers(); // 2s
const posts = await fetchPosts(); // 2s
const comments = await fetchComments(); // 2s
}
// ✅ Parallel: 2 seconds total
async function loadPage() {
const [users, posts, comments] = await Promise.all([fetchUsers(), fetchPosts(), fetchComments()]);
} The rule is simple: if the requests do not depend on each other’s results, run them together. I check for this pattern in every code review, as it is one of the easiest wins available.
Lazy load what you don’t need yet
Don’t pay the cost of loading something until the user actually needs it. The Intersection Observer API makes this trivial for images:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll("img[data-src]").forEach((img) => {
observer.observe(img);
}); Mark your images with data-src instead of src, and they will only load as they scroll into view. For image-heavy pages, this alone can dramatically improve initial load time.
The same principle applies to JavaScript modules. If a feature is only used sometimes, import it dynamically instead of bundling it upfront:
button.addEventListener("click", async () => {
const { heavyFeature } = await import("./heavyFeature.js");
heavyFeature.init();
}); Measure first, optimize second
Here is the advice I wish someone had given me earlier: do not guess at bottlenecks, but measure them.
const start = performance.now();
doExpensiveThing();
console.log(`Took ${performance.now() - start}ms`); Or use console.time for quick checks:
console.time("render");
renderList();
console.timeEnd("render"); // "render: 23.4ms" Open DevTools, run a Performance trace, and let the flame graph show you where time is actually going. Most of the time it is not where you expected.
The short list
When something feels slow, work through these steps:
- Batch DOM changes with document fragments
- Swap CSS classes instead of inline styles
- Use
find()orsome()instead offorEachwhen you need an early exit - Run independent async calls with
Promise.all - Lazy load images and heavy modules
- Measure before and after, every time
Performance work is satisfying precisely because the feedback is immediate. Slow code becomes fast, measured before and after. Go find your slow renders and optimize them.
