Implera is currently offline. The blog stays up.
Back to insights

Insights

Performance Anti-patterns in Modern JavaScript

Performance bugs are often the easiest to fix and the hardest to see. They compile. They pass tests. They do not throw. They just take longer than they should.

A few patterns account for the majority of unnecessary slowness in JavaScript codebases. All are detectable. All are fixable in minutes. Most ship to production anyway because nobody is looking.

Sequential awaits

The most common. Two or more await calls one after another, where the later ones do not depend on the earlier ones.

// broken
const user = await fetchUser(id);
const settings = await fetchSettings(id);
const history = await fetchHistory(id);
return { user, settings, history };

// fixed
const [user, settings, history] = await Promise.all([
	fetchUser(id),
	fetchSettings(id),
	fetchHistory(id),
]);
return { user, settings, history };

Three sequential HTTP calls at 100ms each take 300ms. Three parallel calls take roughly 100ms. No exotic engineering. Just using the language correctly.

Detection is mechanical. Static analysis can find consecutive await expressions where the later expressions do not reference the result of the earlier ones. A good lint rule catches most cases. Add it to CI.

N+1 in async loops

The second most common. A for loop with an await inside it, hitting the network once per iteration.

// broken
const users = await fetchUsers();
for (const user of users) {
	user.orders = await fetchOrders(user.id);
}

// fixed
const users = await fetchUsers();
await Promise.all(
	users.map(async (user) => {
		user.orders = await fetchOrders(user.id);
	}),
);

1,000 users at 100ms per fetch is 100 seconds sequentially or roughly 100ms in parallel. The difference is the difference between a request that times out and one that feels instant.

Caveat: if you are hitting an API with a rate limit, a naive Promise.all over 10,000 calls will get you throttled. Use a concurrency limiter (p-limit or similar) to parallelise with a ceiling.

Importing heavy libraries for trivial work

A common finding on most codebases:

import moment from "moment"; // 290kb
const formatted = moment().format("YYYY-MM-DD");

Moment is deprecated, huge, and here it is used for a one-line date format. Replacements:

// native
new Date().toISOString().slice(0, 10);

// date-fns (tree-shakeable)
import { format } from "date-fns";
format(new Date(), "yyyy-MM-dd");

The moment vs toISOString choice saves 290kb of bundle size for the same output.

Similar offenders:

  • Lodash imported whole (import _ from "lodash") instead of per-function (import debounce from "lodash/debounce"). 70kb vs 2kb.
  • Chalk in a browser build (browsers do not have ANSI terminals). Useless weight.
  • Bluebird in a Node version new enough to have native promises. 60kb for nothing.
  • Axios when native fetch would do. 30kb for a slightly nicer API.

Detection: a bundle analyser (webpack-bundle-analyzer, rollup-plugin-visualizer) shows the top offenders. Look at the biggest chunks. Ask whether each one earns its weight.

Unbounded queries

Database equivalent of N+1. A query that returns every row when only a page is needed.

// broken
const users = await db.users.findAll();
return users.slice(0, 20);

// fixed
const users = await db.users.findAll({ limit: 20, offset: 0 });

The first version loads everything into memory on the app server, then throws away 99% of it. Works fine with 100 rows. Crashes with 100,000. Common sign of code written when the table was small.

Blocking synchronous work in an async function

Synchronous CPU-heavy work inside a function that is nominally async.

// broken
async function processImage(buffer) {
	const result = heavyCpuOperation(buffer); // sync, 2 seconds
	return result;
}

The function is marked async but does not yield. The event loop is blocked for 2 seconds. Every request to the server stalls.

Fix: move the work to a worker thread, a child process, or a queue. worker_threads in Node, Web Workers in the browser. The pattern is the same: sync-heavy work runs off the main loop.

Recomputing derived state in the render path

React, Svelte and Vue all have a version of this. A component that sorts or filters a large array on every render without memoisation.

// broken
function UserList({ users }) {
	const sorted = users.sort((a, b) => a.name.localeCompare(b.name));
	return <ul>{sorted.map(...)}</ul>;
}

sort mutates, runs on every render, and scales O(n log n) with the list. Memoise or compute once.

// fixed (React)
const sorted = useMemo(
	() => [...users].sort((a, b) => a.name.localeCompare(b.name)),
	[users],
);

This is the default-fast version. The framework's own dev tools will often flag the slow version.

What to do

Put the detection in CI. Most of these have lint rules. The sequential-await check is a one-line plugin add. The N+1 check is slightly harder but widely available. The heavy-import check is a bundle-size gate.

Set thresholds on bundle size (fail CI if the main bundle exceeds 200kb compressed, for example). Fail on new findings. Leave existing findings as known issues with tickets, not unknown issues with mystery.

The theme: none of these patterns are exotic. None requires a PhD in performance engineering. Most teams miss them not because they lack skill but because nothing is watching. Put something watching.

FAQ

Common questions

© 2026 Implera