Skip to main content

⚡ Performance Optimization

Your load test just came back with an ugly p99 and a spray of 500s. Now what? The instinct is to start "making things faster." Resist it. The single most important habit in performance work is to measure first — find the one bottleneck that actually matters, fix that, and re-measure. This lesson teaches that loop and the handful of usual suspects it almost always uncovers.

Week 12 · Thursday: Performance Testing · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Apply the measure → find bottleneck → optimize → re-measure loop instead of guessing
  • Profile Node code with the built-in performance API and identify the slow part
  • Recognize the common backend bottlenecks: N+1 queries, missing indexes, no caching, a blocked event loop, and unbounded queries
  • Fix each with the right tool — batching, indexes, caching, async/offloading, and pagination
  • Set performance budgets and thresholds so wins don't silently regress

Estimated Time: 65 minutes

Practice: Diagnose a slow endpoint, fix an N+1 query, and add a cache with re-measured results.

In This Lesson

The Optimization Loop

Performance optimization is not a burst of cleverness — it's a disciplined cycle. You measure to find the truth, change exactly one thing, and measure again to prove it helped. Skip the measuring and you'll pour hours into "optimizations" that speed up code no user ever waits on.

graph LR A["Measure"] --> B["Find the bottleneck"] B --> C["Optimize one thing"] C --> D["Re-measure"] D --> A

There's an old saying worth burning into memory: premature optimization is the root of all evil. It doesn't mean "never optimize." It means don't optimize before you've measured, because your intuition about what's slow is usually wrong. The bottleneck is almost never the fancy algorithm you were proud of — it's the innocent-looking line that runs a database query inside a loop.

📖 Amdahl's law, in one sentence

If a request spends 90% of its time in the database and 10% in your JavaScript, then making your JavaScript twice as fast improves the total by only 5%. Fixing the database is where the win is. Always attack the biggest slice first — that's what "find the bottleneck" means in practice.

Measure Before You Touch

Node.js ships with the same performance API browsers have. Wrap a suspicious section in a mark-and-measure and let the numbers tell you where the time goes.

// Node and browsers both expose performance.mark / performance.measure
performance.mark('handler-start');

const products = await getProducts();      // suspect 1: the query
const enriched = await addReviews(products); // suspect 2: enrichment

performance.mark('handler-end');
performance.measure('handler', 'handler-start', 'handler-end');

const [entry] = performance.getEntriesByName('handler');
console.log(`Handler took ${entry.duration.toFixed(1)} ms`);
// Handler took 812.4 ms   ← now measure each suspect separately to localize it

For a quick timing during development, the console timers are even less ceremony:

console.time('db-query');
const rows = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
console.timeEnd('db-query');
// db-query: 640.187ms   ← the query is the problem, not the JS around it

💡 Measure in the right environment

A query that returns in 5 ms against 100 seeded rows can take 5 seconds against 10 million production rows. Profile against production-like data volumes, or you'll optimize a problem you don't have and miss the one you do. Real user monitoring (RUM) and your load-test reports are your ground truth — not your laptop with an empty database.

Bottleneck: N+1 Queries

This is the number-one performance bug in web backends, and it hides in the most natural-looking code. You fetch a list of N things, then loop over them firing one more query per item — 1 query to get the list, then N queries for the details. Ten posts becomes 11 queries; a thousand posts becomes 1001.

N+1 sends one query per item in a loop; the batched fix sends two queries total regardless of item count N+1 — one query per item get list 1 + N queries Batched — two queries total get list get all details 2 queries, any N
The fix collapses N per-item queries into a single batched query using WHERE id IN (…) — or the ORM's eager-loading option.
// ❌ N+1: one extra query for every post (1 + N round trips to the DB)
const posts = await db.query('SELECT * FROM posts LIMIT 100');
for (const post of posts) {
  post.author = await db.query(
    'SELECT * FROM users WHERE id = $1', [post.author_id]
  ); // fires 100 times!
}

// ✅ Batched: fetch every needed author in ONE query, then stitch in memory
const posts = await db.query('SELECT * FROM posts LIMIT 100');
const authorIds = [...new Set(posts.map(p => p.author_id))];
const authors = await db.query(
  'SELECT * FROM users WHERE id = ANY($1)', [authorIds]
); // 1 query, no matter how many posts
const byId = new Map(authors.map(a => [a.id, a]));
for (const post of posts) post.author = byId.get(post.author_id);

✅ ORMs can fix and cause this

Lazy-loading a relation inside a loop is how ORMs (Sequelize, Prisma, TypeORM) silently create N+1. The same ORMs also give you the cure: eager loading — Sequelize's include, Prisma's include, TypeORM's relations — issues one batched query instead. If an endpoint's query count scales with its result count, you've found an N+1.

Bottleneck: Missing Indexes

A database without an index on a filtered column is a library with no catalog: to find one book, a librarian walks every shelf. That's a full table scan, and it gets linearly slower as the table grows. An index is the catalog — it turns "check every row" into "jump straight to the answer."

-- This query scans every row unless email is indexed
SELECT * FROM users WHERE email = 'ada@example.com';

-- Ask the database what it's actually doing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'ada@example.com';
-- "Seq Scan on users ... rows=2000000"  ← reading two million rows!

-- Add the index once; the query planner uses it automatically forever after
CREATE INDEX idx_users_email ON users (email);
-- Now: "Index Scan using idx_users_email"  ← microseconds, not seconds

Why it matters: the columns to index are the ones you filter (WHERE), join (ON), and sort (ORDER BY) on. EXPLAIN ANALYZE is your x-ray — a "Seq Scan" over a large table on a filtered column is a red flag pointing straight at a missing index.

⚠️ Indexes aren't free

Every index speeds up reads but slows down writes (each INSERT/UPDATE must maintain it) and consumes storage. Index the columns your slow queries actually filter on — not every column "just in case." Measure, add the index the data justifies, re-measure.

Bottleneck: No Caching

The fastest query is the one you never run. If data is expensive to compute and doesn't change every second, cache the result and serve it from memory. A cache is a sticky note on your monitor: instead of walking to the filing cabinet every time, you glance at the note.

// A tiny in-memory cache with a time-to-live (TTL).
// For multi-server production, use Redis instead of a local Map.
const cache = new Map();
const TTL_MS = 60_000; // 60 seconds

async function getPopularProducts() {
  const hit = cache.get('popular');
  if (hit && Date.now() - hit.at < TTL_MS) {
    return hit.value;                 // cache hit — no DB work at all
  }
  const value = await db.query(       // cache miss — do the expensive work once
    'SELECT * FROM products ORDER BY sales DESC LIMIT 20'
  );
  cache.set('popular', { value, at: Date.now() });
  return value;
}

💡 Cache layers, from cheap to serious

  • HTTP cachingCache-Control headers and a CDN let the browser and edge skip your server entirely for static or semi-static responses.
  • In-memory — a Map like above; fast but per-process and lost on restart.
  • Redis / Memcached — a shared cache all your servers read, the standard for anything running on more than one instance.

The hard part of caching isn't storing — it's invalidation: deciding when cached data is stale. A short TTL is the simplest safe answer; explicit invalidation on write is more precise but more work.

Bottleneck: A Blocked Event Loop

Node.js runs your JavaScript on a single thread. That thread — the event loop — juggles every request. If one request runs a long, synchronous computation, every other request waits behind it. One slow CPU task can freeze the whole server.

// ❌ Blocks the event loop: while this runs, NO other request is served
app.get('/report', (req, res) => {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) total += i; // synchronous, seconds long
  res.json({ total });
});

// ✅ Offload CPU-heavy work to a Worker thread; the event loop stays free
import { Worker } from 'node:worker_threads';
app.get('/report', (req, res) => {
  const worker = new Worker('./report-worker.js');
  worker.postMessage(req.query);
  worker.once('message', (result) => res.json(result)); // main thread never blocked
});

Why it matters: most Node work is I/O (database, network, files), which is already asynchronous and non-blocking — that's Node's superpower. The danger is CPU-bound work: parsing huge payloads, image processing, cryptography, giant loops. Keep those off the main thread (worker threads, a queue, or a separate service), and never reach for the synchronous versions of APIs (fs.readFileSync, crypto.pbkdf2Sync) in a request handler.

⚠️ Async doesn't parallelize CPU work

Wrapping a giant synchronous loop in an async function does not unblock the event loop — the loop still runs synchronously to completion before yielding. async helps with waiting (I/O), not with computing. For real CPU parallelism you need worker threads or a separate process.

Bottleneck: Unbounded Queries

An endpoint that returns "all the orders" is a time bomb. It's fast with 50 rows in development and catastrophic with 5 million in production — slow query, huge JSON payload, and a memory spike that can crash the process. The fix is pagination: never return an unbounded set.

// ❌ Returns the entire table — grows unbounded with your data
app.get('/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders'); // could be millions
  res.json(orders);
});

// ✅ Page the results: bounded work, bounded payload, predictable latency
app.get('/orders', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100); // cap it
  const page = Math.max(Number(req.query.page) || 1, 1);
  const offset = (page - 1) * limit;
  const orders = await db.query(
    'SELECT * FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [limit, offset]
  );
  res.json({ page, limit, orders });
});

✅ Cursor pagination scales better than OFFSET

OFFSET is simple but the database still scans and discards every skipped row, so page 10,000 is slow. Cursor (keyset) pagination — "give me the next 20 rows after this id/timestamp" — stays fast at any depth because it seeks straight to the boundary. Reach for it on large, deeply-paged datasets and on infinite-scroll feeds.

Practice & Quiz

🏋️ Exercise 1: Spot and fix the N+1

Goal: This handler is slow under load. Name the bottleneck and rewrite it to use a single batched query for the categories.

const products = await db.query('SELECT * FROM products LIMIT 200');
for (const p of products) {
  p.category = await db.query(
    'SELECT * FROM categories WHERE id = $1', [p.category_id]
  );
}
💡 Hint

Count the queries: 1 for the list plus 1 per product = 201. Collect the unique category_ids and fetch them all with a single WHERE id = ANY(...), then map them back.

✅ Solution
const products = await db.query('SELECT * FROM products LIMIT 200');
const ids = [...new Set(products.map(p => p.category_id))];
const categories = await db.query(
  'SELECT * FROM categories WHERE id = ANY($1)', [ids]
);
const byId = new Map(categories.map(c => [c.id, c]));
for (const p of products) p.category = byId.get(p.category_id);
// 2 queries total instead of 201

🏋️ Exercise 2: Choose the fix

Goal: For each symptom, name the most likely bottleneck and its fix.

  1. A search endpoint's latency grows linearly as the users table gets bigger; EXPLAIN shows a Seq Scan.
  2. The same expensive "trending" query runs on every homepage load, though the answer barely changes minute to minute.
  3. One /export request makes the whole server unresponsive for four seconds.
✅ Solution

1 → Missing index on the searched column; add one and the Seq Scan becomes an Index Scan. 2 → No caching; cache the result with a short TTL (or in Redis) so most loads skip the query. 3 → Blocked event loop from CPU-bound work; offload it to a worker thread or a background job so the main thread keeps serving.

🎯 Quick Quiz

Question 1: What is the first step of responsible optimization?

Question 2: An endpoint's database query count rises with the number of results it returns. What's the bug?

Question 3: Why must CPU-heavy work be kept off the Node main thread?

Best Practices & Pitfalls

✅ Do

  • Measure first, change one thing, then re-measure to prove the win
  • Attack the biggest slice of the time budget, not the easiest-looking code
  • Use EXPLAIN ANALYZE to see what the database is really doing
  • Cache expensive, slowly-changing data — and set a clear TTL or invalidation rule
  • Paginate every list endpoint and cap the page size
  • Lock in gains with a performance budget and CI thresholds so they can't silently regress

❌ Don't

  • Optimize on a hunch before profiling — you'll speed up code no one waits on
  • Run queries inside loops (the N+1 trap)
  • Do CPU-bound work synchronously in a request handler
  • Return unbounded result sets
  • Add indexes to every column — writes and storage pay the price

📖 Turn a win into a guardrail

You dropped p95 from 800 ms to 180 ms — great. Now stop it from creeping back. Add the threshold to your Artillery or k6 run (p95 < 250) and a Lighthouse budget for the frontend, both in CI. A future change that reintroduces the N+1 will fail the build instead of surprising a user in production.

Summary

🎉 Key Takeaways

  • Optimization is a loop: measure → find the bottleneck → fix one thing → re-measure
  • Measure first — intuition about what's slow is usually wrong, so profile before you change code
  • The usual suspects are N+1 queries, missing indexes, no caching, a blocked event loop, and unbounded queries
  • The fixes map cleanly: batch queries, add indexes, cache, offload CPU work, and paginate
  • Protect every win with a performance budget and CI thresholds so it can't silently regress

📚 Additional Resources

🚀 What's Next?

You can now measure, diagnose, and fix the bottlenecks that load tests expose. That completes the performance-testing arc. Next we zoom out to the pipeline that runs all of this automatically on every commit: CI/CD Concepts — where your tests, load thresholds, and performance budgets become gates that protect production.

🎉 Bottleneck, meet fix!

You've learned to let evidence — not instinct — drive optimization, and to recognize the five bugs behind most slow backends. That's how senior engineers make things fast.