The golden rule: the fastest work is the work you do not do
Optimizing performance is, almost always, avoiding repeated work: not recomputing, not asking again, not sending what is already there. The central tool for that is the cache, and it helps to think of it in tiers.
Tiered caching
Browser / HTTP: the client stores responses based on the
Cache-ControlandETagheaders. If the resource has not changed, the server replies304 Not Modifiedand does not resend the body. Network cost: zero.CDN for static assets: a network of servers close to the user serves images, JS and CSS from the edge, without touching your origin. Closer = less latency and less load on your backend.
Backend cache (Redis) with the cache-aside pattern:
is it in the cache? → yes → return it (fast) → no → go to the DB, store the result in the cache, return itThe application manages the cache "on the side" of the DB. You have to decide the TTL (how long the data lives) and invalidate the entry when the data changes, or you will serve stale data. Invalidating a cache is one of the truly hard problems.
On the frontend: send less and later
- Lazy loading: load things when they are needed, not at startup (images on scroll, a route when you visit it).
- Code splitting: split the bundle into chunks and download only the one the current screen needs. The user does not pay for code they are not using yet.
- Compression: the server compresses text responses (HTML, JS, CSS, JSON) with gzip or brotli (better ratio) before sending them. A 300 KB JS file can travel as 80 KB.
In the database: indexes and the N+1 problem
The DB is usually the bottleneck. Two classics:
- Indexes: without an index, finding a row means scanning the whole table
(full scan). An index on the column you filter by (
WHERE email = ?) turns that scan into a near-instant lookup. They cost something in writes and space, so you index the columns you filter and sort by, not all of them. - The N+1 problem: you request a list of N items (1 query) and then, for
each one, run another query for its related data. Result: 1 + N queries.
With 100 orders that is 101 trips to the DB. The solution is to batch:
load the data for all N in a single query (
WHERE id IN (...)) or with aJOIN.
Measure before optimizing. "I think this is slow" is not data; a p95 and a trace are. Optimizing blindly adds complexity with no guarantee of improvement.
Examples
Cache-aside with a Map: do not recompute what is already computed
function cache(fn) {
const store = new Map();
return function (key) {
if (store.has(key)) return store.get(key);
const value = fn(key);
store.set(key, value);
return value;
};
}
let calls = 0;
const square = cache((n) => { calls++; return n * n; });
square(8); square(8); square(8);
console.log("result:", square(8), "actual calls:", calls); // 64, 1
N+1 vs. batching: 1 query instead of N
// Simulates a "bulk" load of several ids at once.
function loadUsers(ids) {
console.log("DB queries:", 1, "for", ids.length, "ids");
return ids.map((id) => ({ id, name: "User " + id }));
}
const ids = [1, 2, 3, 4, 5];
const users = loadUsers(ids); // 1 trip, not 5
console.log(users.length, "users loaded");