Full-Stack Questions
Full-Stack Performance: From Database to Browser
Find the bottleneck first, then fix the database, caching, compression, streaming and bundle size, and measure each change with p50, p95 and p99.
On this page
- Words used in this lesson
- Part 1. Find the bottleneck first
- Part 2. The database
- Part 3. Caching layers and what to cache
- Part 4. HTTP compression and caching headers
- Part 5. Streaming and Suspense in Next.js
- Part 6. Images, fonts and bundle size
- Part 7. Load testing and how to read p50, p95 and p99
- Part 8. The experiment: seven ways to answer the same request
- Part 9. Measuring before and after with numbers
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: to make a full-stack app fast, find the slowest step first, change one thing, and measure again with percentiles (p50, p95, p99); most of the big wins are boring ones: an index, one query instead of twenty, a reused connection, a cache that fits the data, and sending the first byte early.
"The page is slow" is not a bug report. A request goes through the browser, the network, your server, your database and back. Time can be lost in any of them, and a fix in the wrong place changes nothing. Interviewers ask this topic to see if you work by evidence or by guessing.
This lesson does not repeat the basics. For the Core Web Vitals numbers (LCP, INP, CLS) see Browser, Lesson 09. For the question "the API is fast but the UI is slow" see Debugging, Lesson 01, and for the opposite case see Debugging, Lesson 02. For Redis, cache patterns and stampedes see Full-Stack Q&A, Lesson 18. For indexes, MVCC, locks and PgBouncer in more depth see Lesson 27. For HTTP cache headers see Browser, Lesson 05. Here we connect them into one path from the database to the browser, and we measure each step.
What I ran. One Linux machine with 4 CPU cores and 16 GB of RAM. Node.js 22.22.0, PostgreSQL 16.15 (on the same machine, over a Unix socket), node-postgres 8.23.1, autocannon 8.0.0 and k6 1.4.0. The streaming test used the Next.js 16.3.5 that is installed in this blog's repo, built with next build --webpack (Turbopack refused my symlinked node_modules) and served with next start. The load generator and the server ran on the same machine, so they competed for the same CPU. That makes every absolute number here a local number, not a universal one. I draw only ratios and shapes from them (for example "about 100 times fewer requests per second without the index"), and I tell you which. The Next.js, Postgres, k6 and autocannon facts that I did not run come from their docs, and I say so where it matters. Where I say "I ran", I did.
Words used in this lesson
- Latency is how long one request takes, from sending to getting the full answer. Throughput is how many requests finish per second. They are linked, but they are not the same number.
- A percentile is a position in a sorted list of timings. p50 (the median) is the time that half of the requests beat. p95 is the time that 95 out of 100 requests beat, so 5 are slower. p99 is the same for 99 out of 100. The slowest few are called the tail.
- A bottleneck is the one step that limits the whole system. Making any other step faster does not help.
- A query plan is the list of steps that the database chose to answer a query.
EXPLAIN ANALYZEshows that plan and also runs the query to show real times. - A sequential scan (seq scan) reads the whole table. An index scan uses an index to jump to the matching rows.
- N+1 is a pattern where you run 1 query to get a list, then N more queries, one for each item in the list.
- A connection pool is a small set of open database connections that your code borrows and returns, so each request does not open a new one.
- A cache is a stored copy of an answer, so you do not compute it again. A cache hit is when the copy is found. A TTL (time to live) is how long a copy may be used.
- TTFB (time to first byte) is the time until the first byte of the response arrives.
- Streaming means sending a response in parts while the server is still working on the rest. Suspense is the React tool that marks which part of the page may arrive later, and what to show in the meantime (the fallback).
- A load test sends many requests on purpose, to see how the system behaves under pressure. A closed model test keeps a fixed number of connections busy: a new request starts when the last one ends. An open model test sends requests at a fixed rate, whatever the server does.
- Coordinated omission is a measuring mistake in closed tests: when the server stalls, the test stops sending, so the slow period is under-counted and the percentiles look better than the truth.
Part 1. Find the bottleneck first
The method is the same one from Debugging, Lesson 00: measure, isolate, change one thing, measure again. For a full-stack path, "isolate" means: which layer owns most of the time?
| Where you look | What it tells you | Tool |
|---|---|---|
| Browser, Network panel: "Waiting for server response" (TTFB) | Is the server slow, or is it the network? | DevTools |
| Browser, Performance panel: long tasks | Is the main thread busy after the data arrives? | DevTools (see Debugging 01) |
| Server: time per request, per route | Which route is slow? Does the time grow with load? | Logs, Server-Timing header, APM |
| Server: time spent waiting for the database | Is the query slow, or are there too many queries? | Query log, pg_stat_statements |
| Database: the plan of the slow query | Scan, join, sort or lock? | EXPLAIN (ANALYZE, BUFFERS) |
| Load test | Does it get slower when many users come at once? | autocannon, k6 |
Two habits make this work:
- Decide what "fast enough" means before you start. For example "p95 under 300 ms at 200 requests per second". Without a target you never know when to stop.
- Measure the same thing before and after. Same data, same machine, same load, several runs. One run is a story. Three runs are a result.
Do not optimise by feeling. The classic wrong guess is "the database is slow" when the real cause is 21 round trips from the server, or "the server is slow" when the browser spends 800 ms running JavaScript. Look at the numbers first. Read the debugging lessons linked above for the browser side; this lesson covers the server side.
A useful quick check is the Server-Timing response header. It lets your server report its own parts of the time, and the browser DevTools shows them in the Network panel. For example Server-Timing: db;dur=3.1, render;dur=7.4. It costs almost nothing and ends many arguments.
Part 2. The database
Reading EXPLAIN ANALYZE
I made a table orders with 1,000,000 rows (50 MB of table data) and 10,000 users, so each user has 100 orders. The query is the typical "latest 20 orders of one user":
EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;Without a useful index, Postgres 16 chose this (I removed some lines):
Limit (actual time=38.383..46.810 rows=20 loops=1) Buffers: shared hit=6484 -> Gather Merge (actual time=38.380..46.805 rows=20 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=34.286..34.289 rows=17 loops=3) Sort Key: created_at DESC Sort Method: top-N heapsort Memory: 26kB -> Parallel Seq Scan on orders (actual time=0.228..34.161 rows=33 loops=3) Filter: (user_id = 42) Rows Removed by Filter: 333300 Buffers: shared hit=6370 Execution Time: 46.952 msThen I added a composite index and ran the same query again:
CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC); Limit (actual time=0.043..0.180 rows=20 loops=1) Buffers: shared hit=20 read=3 -> Index Scan using orders_user_created_idx on orders (actual time=0.042..0.177 rows=20 loops=1) Index Cond: (user_id = 42) Execution Time: 0.229 msHow to read these, from the bottom of the tree up:
| What you see | Meaning |
|---|---|
Parallel Seq Scan | The whole table was read, by 3 processes (loops=3). |
Rows Removed by Filter: 333300 | Each process threw away 333,300 rows to keep 33. A big number here means a missing index. |
Buffers: shared hit=6484 | Postgres touched 6,484 pages (8 kB each) in memory. With the index it touched about 23 (hit=20 read=3). read means it had to go to the disk or the OS cache. |
actual time=A..B | A is the time to the first row, B to the last row. It is per loop. |
loops=N | Multiply the per-loop time by N to get the total for that node. |
rows= estimate versus actual | A big gap means the planner guessed wrong. See Lesson 27 (stale statistics, CREATE STATISTICS). |
Sort Method: top-N heapsort | Fine. external merge Disk: means the sort spilled to disk, which is slow. |
The machine-independent conclusion here is not "47 ms became 0.2 ms" (that depends on my CPU and cache). It is: the work changed from reading about 6,400 pages to reading about 23 pages. Pages read is a count, and it is the same on any machine. The index in the second plan is 30 MB, so it costs disk space and slows writes; I have not measured the write cost.
EXPLAIN ANALYZE really runs the query. For an UPDATE or DELETE, wrap it in BEGIN; ... ROLLBACK;. And run it twice: the first run may be slow because the data was not yet in memory (the read=3 above).
Indexes themselves, partial and covering indexes, and the order of columns are in Lesson 27. Two rules are worth repeating here. In a composite index, put the equality column first (user_id), then the sort column (created_at): the database can then read the rows already in order and stop after 20. And a function on the column (WHERE created_at::date = ...) hides the index.
N+1: many small queries
A database query is fast, but each one is also a round trip: send the query, wait, receive. Twenty-one round trips cost much more than one, even if each query is tiny.
// N+1: 1 query for the list, then 1 query per row (21 queries)const { rows } = await pool.query('SELECT id, user_id, total FROM orders WHERE id >= $1 ORDER BY id LIMIT 20', [start]);for (const o of rows) { const u = await pool.query('SELECT name FROM users WHERE id = $1', [o.user_id]); o.name = u.rows[0].name;}// One query with a joinconst { rows } = await pool.query( `SELECT o.id, o.user_id, o.total, u.name FROM orders o JOIN users u ON u.id = o.user_id WHERE o.id >= $1 ORDER BY o.id LIMIT 20`, [start]);Inside Postgres, the join took 0.485 ms (EXPLAIN ANALYZE; it looked up the 20 users with an index, loops=20). In the HTTP test (Part 8) it was about 6 times faster in requests per second than the N+1 version. The point: the time is lost between your server and the database, not inside the database.
N+1 hides in ORMs and in GraphQL resolvers, because the loop is not visible in the code. How to find it: count the queries per request (turn on the query log in development), and look for the same query shape repeated. Fixes: a join, WHERE id = ANY($1) with a list of ids, or a batching tool (a DataLoader, or Prisma's include). See Backend, Lesson 08 and Lesson 08.
A join is not always the answer. A join that multiplies rows (one order with many items) can return a huge result with repeated data. Sometimes two queries (WHERE id = ANY(...)) are better than one big join. Measure.
Connection pooling
Opening a Postgres connection is expensive: a new TCP (or Unix) connection, authentication, and on the server a new operating system process. So a server keeps a pool. With node-postgres, new Pool() uses a maximum of 10 connections by default (I read it in the pg-pool source: this.options.max = ... || 10). A request borrows a connection and gives it back. If all 10 are busy, the next query waits in a queue.
Three things to know:
- Too small a pool makes requests wait in your app. Too large a pool makes the database do too much at once. My local server had
max_connections = 100(the default of my install), and each connection is a process, so thousands of connections are not an option. - Every app instance has its own pool. 20 instances with a pool of 10 means up to 200 connections.
- For many instances or serverless functions, put a pooler in front (PgBouncer). The modes, and what breaks with
SETand prepared statements, are in Lesson 27.
In Part 8 I compare the same query with a pool and with a new connection per request.
Part 3. Caching layers and what to cache
A request can be answered at many places. The closer to the user, the cheaper the answer, and the harder it is to change.
| Layer | What is stored | Who controls it | Main risk |
|---|---|---|---|
| Browser cache | Files, API responses with cache headers | Cache-Control, ETag | The user keeps an old copy |
| CDN (edge) | Static files, public pages | s-maxage, purge API | Wrong data shared between users |
| Framework cache (Next.js) | Rendered pages, data results | revalidate, tags, cacheLife | Forgetting to revalidate after a write |
In-process memory (a Map) | Small hot data | Your code | Each instance has its own copy; lost on restart |
| Shared cache (Redis) | Computed results, sessions | Your code, TTL | Invalidation, stampede (Lesson 18) |
| Database | Pages in memory (shared_buffers, OS cache) | Postgres | Not yours to manage, but it is why the second EXPLAIN is faster |
What is worth caching
A good cache candidate has all of these:
- It is expensive to compute (a slow query, a call to another service), or it is asked very often.
- It is read much more than it is written.
- It can be a little stale without harm (a product list, yes; an account balance before a payment, no).
- It is the same for many users, or it has a key that separates users safely.
Do not cache what is cheap. Caching adds code, memory and a new way to be wrong. My own test below shows this clearly: caching a join that already takes about 0.5 ms in the database gave only a small gain. Fix the query first, cache second.
Rules that avoid the usual bugs (details in Lesson 18):
- Put a TTL on everything, so a mistake heals itself.
- Make the cache key include everything the answer depends on: user id, language, query parameters. A missing part of the key shows one user's data to another.
- Never put private data in a shared cache (CDN) without
privateor a key per user. - Plan invalidation: on a write, delete or update the key, or accept staleness until the TTL ends.
- Protect against a stampede: when a popular key expires, many requests recompute it at once. Use a lock, a short random extra TTL, or serve the stale value while one request refreshes it.
- Watch the hit ratio (hits divided by hits plus misses). A cache with a low hit ratio is only extra work.
Part 4. HTTP compression and caching headers
Compression
Text compresses well. I took 1,000 rows of JSON (order id, user id, price, name, timestamp), which is 100,354 bytes, and compressed it with Node's built-in zlib:
| Method | Size in bytes | Share of original | Time to compress (my machine) |
|---|---|---|---|
| none | 100,354 | 100% | 0 |
| gzip level 1 | 15,241 | 15.2% | about 1.5 ms |
| gzip level 6 (the usual default) | 13,782 | 13.7% | about 1.5 ms |
| gzip level 9 | 13,663 | 13.6% | about 4.5 ms |
| brotli quality 4 | 8,145 | 8.1% | about 2 to 7 ms |
| brotli quality 11 (the maximum) | 5,884 | 5.9% | about 180 to 190 ms |
The conclusions that do not depend on my machine: compression cut this JSON to about one seventh or less; going from gzip level 6 to level 9 gains almost nothing but costs three times the time; and the highest brotli level is far too slow to run on every response. Use it for static files that you compress once at build time, and use a low or middle level for dynamic responses. My data is repeated and regular, so it compresses better than most real data. Already-compressed files (JPEG, PNG, WebP, video) should not be compressed again.
The client says what it accepts with Accept-Encoding: gzip, br, and the server answers with Content-Encoding: gzip and Vary: Accept-Encoding (so a cache stores one copy per encoding). In Next.js, next start compresses with gzip by default (compress: true). I checked: with Accept-Encoding: gzip the response had Content-Encoding: gzip. If you use nginx or a CDN that does brotli, the Next.js docs say to set compress: false so you do not compress twice.
Caching headers
The browser side is in Browser, Lesson 05. The short table you need for performance:
| Content | Header | Why |
|---|---|---|
Hashed static files (/_next/static/...) | public, max-age=31536000, immutable | The file name changes when the content changes, so it can be kept for a year. Next.js sets this itself (Next.js docs, "CDN Caching"). |
| Public page that changes sometimes | s-maxage=60, stale-while-revalidate=300 | The CDN may serve it for 60 s, then serve the old copy while it fetches a new one. |
| Personal or private data | private, no-store | Never in a shared cache. |
| Dynamic page in Next.js | private, no-cache, no-store, max-age=0, must-revalidate | This is what I saw on my dynamic test page. |
ETag and Last-Modified let the browser ask "has it changed?" and get a tiny 304 Not Modified instead of the whole body. This saves bandwidth, not the server's work, unless your server can answer the question without doing the full render.
Part 5. Streaming and Suspense in Next.js
Without streaming, the server must finish all the data work before it sends the first byte. One slow query then delays the whole page, including the parts that were ready at once. With streaming, the server sends the page shell (layout and fallbacks) at once, and sends each slow part when it is ready (this is how the Next.js "Streaming" guide describes it; it uses chunked transfer encoding and React's <Suspense> boundaries).
I built two pages. Both have a slow part that takes 1000 ms. The first has no Suspense. The second wraps the slow part in <Suspense>:
// app/blocking/page.jsx (no Suspense)export default async function Page() { await connection(); // make the route dynamic return (<main><h1>Shell</h1><Slow /></main>); // Slow awaits 1000 ms}// app/stream/page.jsx (with Suspense)export default function Page() { return (<main><h1>Shell</h1> <Suspense fallback={<p>loading...</p>}><Slow /></Suspense></main>);}I requested each page three times with curl -w "ttfb=%{time_starttransfer} total=%{time_total}":
| Page | TTFB (3 runs) | Total (3 runs) |
|---|---|---|
/blocking | 1.071 s, 1.013 s, 1.013 s | 1.072 s, 1.013 s, 1.014 s |
/stream | 0.018 s, 0.013 s, 0.011 s | 1.014 s, 1.010 s, 1.009 s |
The response had Transfer-Encoding: chunked, and the first chunk already contained <h1>Shell</h1> and <p>loading...</p>. With Accept-Encoding: gzip the page was still streamed (TTFB 0.012 s). The conclusion that holds on any machine: streaming does not make the slow work faster. The total stayed at about 1 second. It moves the first byte, and the visible shell, from about 1 s to about 10 to 20 ms. The browser can start loading CSS and JavaScript, and the user sees something.
Rules for using it well:
- Put a
<Suspense>boundary around the slow part, not around the whole page. Sibling boundaries load independently and do not block each other.loading.jsis a shortcut that wraps a whole page segment. - Start slow requests in parallel. Two awaits in a row add up. Start both promises first, or give each its own Suspense boundary.
- A page that streams has already sent status 200 when the shell goes out, so a later
notFound()cannot change the status code (see Lesson 27). - Proxies can ruin it. The Next.js self-hosting guide says that nginx and similar proxies need buffering turned off (
X-Accel-Buffering: no), and that some load balancers buffer by default. If TTFB is still slow in production but fast on your machine, check for buffering. - Streaming helps TTFB and often LCP (see Browser, Lesson 09), but a big fallback or a layout that jumps when the real content arrives can hurt CLS. Give the fallback the same size as the content.
Part 6. Images, fonts and bundle size
The browser has to download, parse and run what you send. This is the usual cause when "the API is fast but the UI is slow" (Debugging, Lesson 06 covers it).
Images
Images are often the biggest files and the LCP element. What to do with next/image (from the Next.js docs):
- Always give
widthandheight(or usefill). The browser then reserves the space and the page does not jump (CLS). - Use the
preloadprop (Next.js 16; the oldpriorityprop is deprecated) only for the main image above the fold. The docs say that in most casesloading="eager"orfetchPriority="high"is enough. Everything else is lazy by default (loading="lazy"). - Use the
sizesprop so a phone does not download a desktop-sized file. - The default output format is WebP. You can add AVIF (
formats: ['image/avif', 'image/webp']), but the docs warn that each format is cached separately, so the cache grows. - Static imports get a content hash and are cached "forever" with
immutable.
Fonts
next/font downloads the font at build time and serves it from your own domain, so the browser makes no request to Google. It also helps avoid layout shift. Use a variable font (one file for all weights) when you can, limit subsets, and load only the weights you really use. Every extra font file is a request and a chance of a flash of wrong text.
Bundle size
JavaScript costs more than its download size, because the browser must also parse and run it. Steps:
- Look first. In Next.js 16.1 and later there is an experimental analyzer:
npx next experimental-analyze(Turbopack). For webpack there is the@next/bundle-analyzerplugin. Both are described in the Next.js "Package bundling" guide. - Keep the client small. Server Components send no component JavaScript to the browser. Mark only the interactive part with
'use client', not the whole tree. - Lazy load what is not needed at first: a modal, a chart, an editor. Use
next/dynamicorReact.lazywith Suspense. - Import less. Import one function, not the whole library. Replace a heavy library with the platform (
Intl,fetch,structuredClone) when you can. - Set a budget (for example "main JavaScript under 170 kB gzipped") and fail the build if it is exceeded. Choose your own number, based on your users' devices.
Part 7. Load testing and how to read p50, p95 and p99
Two tools
autocannon is a small Node tool. You give it a number of connections and a duration:
npx autocannon -c 20 -d 10 http://127.0.0.1:3099/join-c is the number of connections, -d is the duration in seconds. It is a closed model tool: each connection sends the next request when the last one finishes. Its latency table shows the 2.5th, 50th, 97.5th and 99th percentiles (from its README), so it has no p95; I use p97.5 as the "slow outlier" number. With -R (overall rate) it sends at a fixed rate and corrects for coordinated omission.
k6 (Grafana) uses JavaScript test scripts and has thresholds that fail the run. Its constant-arrival-rate executor is an open model: it starts a fixed number of requests per second whatever the server does.
// k6 script (I ran this)import http from 'k6/http';import { check } from 'k6';export const options = { scenarios: { open: { executor: 'constant-arrival-rate', rate: 1500, timeUnit: '1s', duration: '8s', preAllocatedVUs: 100, maxVUs: 400 } }, thresholds: { http_req_duration: ['p(95)<100', 'p(99)<200'], http_req_failed: ['rate<0.01'] },};export default function () { check(http.get('http://127.0.0.1:3099/join'), { ok: (r) => r.status === 200 }); }In k6, http_req_duration is the time of sending, waiting and receiving, without the DNS and connection time (k6 metrics reference). dropped_iterations counts requests that k6 could not start because it ran out of virtual users.
Closed or open: why it matters
A real website is an open system: users arrive when they want, not when your last request ended. If the server slows down, users still arrive, and a queue grows. A closed test hides this, because the test "waits politely". I ran the same N+1 endpoint in an open test:
| N+1 endpoint, k6 open model | Requests per second reached | p50 | p95 | p99 | Dropped iterations |
|---|---|---|---|---|---|
| asked for 400 per second | 400 | 5.4 ms | 10.9 ms | 18.5 ms | 0 |
| asked for 1500 per second | 663 | 557 ms | 721 ms | 739 ms | 6,492 |
At 400 per second the endpoint was healthy. At 1500 per second it could only do about 660, and latency went up by a factor of about 100 and the queue grew. The lesson that holds everywhere: a system has a knee. Below it, latency is flat. Above it, latency explodes. The right question is not "what is the average?" but "at what rate does p99 leave our target?"
Reading the percentiles
- p50 shows the typical request. p95 and p99 show what unlucky users feel. Averages hide the tail. Report percentiles, never only the mean.
- The tail adds up across calls. If one page view makes 20 calls and each call is slower than its p99 only 1% of the time, then the share of page views with no slow call is 0.99 to the power of 20, which is about 82%. So about 18% of page views get at least one slow call (this assumes the calls are independent, which is only roughly true).
- Watch the max and the shape. A p99 far above p95 usually means rare events: garbage collection, a lock, a cold cache, a connection being opened.
- In a closed test, latency and throughput are one number seen twice. With 20 connections, the average latency is about 20 divided by the requests per second. This is Little's law: the number of requests in the system equals the throughput times the average time each one spends there. My results agree: 20 / 4,940 per second is about 4 ms, close to the median of 3 ms; and 20 / 46 per second is about 435 ms, close to the 412 ms median of the no-index test. So you cannot improve throughput "and" latency separately in a closed test. They move together.
- Warm up. The first run in my test had worse tails than the next two (the N+1 endpoint had p99 of 129 ms in run 1, and 50 and 46 ms in runs 2 and 3). Throw away the first run, or run longer.
- Do not run the load tool on the machine you test, if you can avoid it. I did, so my top numbers are limited by shared CPU.
Part 8. The experiment: seven ways to answer the same request
All variants return 20 rows as JSON from the same Node server. The server picks a random key from 9,000 values for each request, so there is no one hot row. I ran each variant with autocannon -c 20 -d 6 (20 connections, 6 seconds), three times each in rotation (all variants, then all again, then again), and I report the median of the three runs.
The variants:
| Name | What it does |
|---|---|
noindex | Latest 20 orders of one user, from a copy of the table with no index on user_id |
indexed | The same query on the table with the index (user_id, created_at DESC) |
n1 | 20 orders and then 1 query per row for the user name (21 queries) |
join | The same data with one JOIN query |
nopool | A simple 20-row query, but with a new connection for every request |
pooled | The same simple query with a shared Pool (max 10) |
cached | The join result kept in a Map in memory for 60 s (cache-aside) |
The cache part of the code, for example:
const cache = new Map(); // key -> { at, rows }async function cached(start) { const hit = cache.get(start); if (hit && Date.now() - hit.at < 60_000) { hits++; return hit.rows; } misses++; const rows = await join(start); // the one-query version cache.set(start, { at: Date.now(), rows }); return rows;}Results (median of three runs, 20 connections):
| Variant | Requests per second | p50 | p97.5 | p99 |
|---|---|---|---|---|
noindex | 46 | 412 ms | 634 ms | 672 ms |
indexed | 5,943 | 2 ms | 7 ms | 8 ms |
n1 | 777 | 23 ms | 42 ms | 50 ms |
join | 4,940 | 3 ms | 7 ms | 9 ms |
nopool | 425 | 45 ms | 76 ms | 82 ms |
pooled | 5,133 | 3 ms | 8 ms | 9 ms |
cached | 9,843 | 1 ms | 5 ms | 7 ms |
There were no errors and no non-2xx answers in any run. During the cached runs the hit ratio over the whole session was about 91% (177,383 hits and 18,002 misses).
What I conclude, and how sure I am:
- The index is worth about two orders of magnitude in this setup (46 to 5,943 requests per second, p50 from 412 ms to 2 ms). The size of the gap depends on the table size, and the shape does not: a seq scan costs more as the table grows, an index lookup barely does. This is the biggest and most certain win.
- N+1 cost about 6 times the throughput and gave a p99 about 5 times worse than the join (50 ms against 9 ms). The cause is 21 round trips. The ratio depends on how far the database is from the server: on my machine it is a Unix socket on the same host, which is the best case for N+1. With a network between them, N+1 would be worse. That last sentence is an expectation from how round trips work, not something I measured.
- A new connection per request cost about 12 times the throughput against a pool. Again this is the best case for "no pool": a local socket and no TLS. With a network and TLS it would be worse.
- The cache gave only about 2 times the throughput over the join, and the p99 went from 9 ms to 7 ms. The join was already about 0.5 ms in the database, so there was little to save. The cache helps most when the original work is slow, and here the Node server itself (JSON, HTTP) became the limit. So: fix the query first, then decide if you still need a cache.
- The order of the wins (index, then N+1, then pool, then cache) matches the advice "do the cheap, structural fixes first". The exact numbers will be different on your machine. If you reproduce the test, expect the same order and similar ratios, not the same milliseconds.
What these numbers do not show. They do not show the effect of network distance, TLS, a bigger working set than memory, concurrent writes, or many different queries at once. The load tool and the server shared 4 cores. Treat the table as a worked example of the method (change one thing, repeat three times, compare ratios), not as a benchmark of Node or Postgres.
To run it yourself you need a Postgres, the setup SQL (two tables, 1,000,000 orders, an ANALYZE, and an index for the indexed route), a small Node server with the seven routes and pg, and npx autocannon. The pieces are all shown above; the rest is plain http.createServer and pool.query.
Part 9. Measuring before and after with numbers
A short checklist for any performance change you want to defend:
- Write the target (for example "p95 under 100 ms at 500 requests per second").
- Record the baseline: the number, the date, the commit, the machine, the data size and the tool settings.
- Change one thing. If you change an index and add a cache together, you do not know which one helped.
- Repeat at least three runs, drop the warm-up run, and report the median and the spread.
- Compare ratios and counts (pages read, queries per request, bytes sent), not only milliseconds. Counts are stable across machines.
- Check in production. Use real-user data for the browser (see Core Web Vitals in Browser, Lesson 09) and percentiles from your server metrics. A test shows a possibility; production shows the truth.
- Write down what you did not fix, and what you saw get worse (write speed, memory, complexity).
Common mistakes
- Optimising before measuring, and fixing the layer that was not slow.
- Reporting only the average. The tail is what users remember.
- Testing with one hot row or one URL, so the database cache makes everything look fast.
- Running one test run, or comparing a cold run with a warm one.
- Adding a cache before fixing the slow query, then fighting stale data forever.
- A cache key that misses the user or the language, so users see each other's data.
- A pool of 100 per instance on 30 instances, so the database runs out of connections.
- Loading a big library for one function, or marking a whole page
'use client'. - Using a closed test to claim "we handle 5,000 requests per second", when real users arrive at a fixed rate.
- Forgetting that a proxy buffers the response, so streaming works locally and not in production.
Interview questions
A page is slow. What do you do first? (Must know)
I do not guess. I split the time by layer. In the browser Network panel I look at TTFB: if it is high, the server is slow; if the server answer is quick but the page is still slow, the time is in JavaScript or rendering, and I move to the Performance panel. For a slow server I find the slow route, then count the queries and look at the time in the database. I set a target first, for example "p95 under 300 ms", so I know when to stop. Then I change one thing and measure again. I would not add a cache or scale up before I know where the time goes.
How do you read an EXPLAIN ANALYZE plan? (Must know)
I read from the innermost node outward. I look for a Seq Scan on a big table with a large Rows Removed by Filter, which means a missing index. I compare estimated rows with actual rows, because a big gap means the planner guessed wrong. I check loops and multiply the per-loop time. I check Buffers, which tell me how many pages were touched. In my test the same query went from a parallel seq scan with 6,484 buffer hits to an index scan with 23 pages. I also remember that ANALYZE runs the query, so I wrap writes in a transaction that I roll back, and I run it twice because the first run may be a cold cache.
What is N+1, and how do you find and fix it? (Must know)
N+1 is one query for a list and then one more query for each item. Each query is fast, but each is a round trip, so 21 queries cost much more than 1. I find it by counting queries per request, with a query log in development or a trace, and by looking for the same query repeated. I fix it with a join, an id = ANY(...) query, or a batching tool like a DataLoader or Prisma's include. In my test the join version had about 6 times the throughput of the N+1 version on a local database, and I expect the gap to be larger over a network, but I did not measure that.
Follow-up: "Is a join always better?" No. A join that multiplies rows can return a lot of repeated data. Two queries with ANY can be better. I measure both.
Why use a connection pool, and how do you size it? (Must know)
A new Postgres connection costs a connection setup, authentication and a new server process. A pool reuses connections. In my test, a new connection per request gave about 425 requests per second against 5,133 with a pool, on a local socket with no TLS, so the real gap on a network is usually larger. For the size I start small. node-postgres defaults to 10. The database has a limit too (max_connections was 100 on my install), and every app instance has its own pool, so 20 instances with 10 each means 200. If I have many instances or serverless functions, I put PgBouncer in front, and I know that transaction mode breaks SET and session state (Lesson 27).
A query is fast in the database but the endpoint is slow. Why? (Senior signal)
The time is probably outside the query. Common causes: many round trips (N+1), waiting in the pool queue because all connections are busy, opening a new connection, serialising a huge result to JSON, or the network. In my test the join took 0.485 ms inside Postgres, but the endpoint median was 3 ms, and the N+1 version was 23 ms with the same fast queries. I would add timing around the pool wait, the query and the serialisation, and send it with Server-Timing.
What do you cache, and what do you not cache? (Must know)
I cache work that is expensive or very frequent, read much more than written, safe to be a little stale, and the same for many users. I do not cache cheap work: in my test, caching a join that already took about 0.5 ms in the database gave only about 2 times the throughput, and the server itself became the limit. I always set a TTL, I make the key include everything the answer depends on, and I never put private data in a shared cache. I plan invalidation and protect against a stampede (Lesson 18). I watch the hit ratio, because a cache with a low hit ratio is extra work.
Name the cache layers between the user and the database. (Must know)
Browser cache, CDN, the framework cache (in Next.js: rendered pages and data results with revalidate or tags), in-process memory, a shared cache like Redis, and the database's own memory (shared buffers and the OS cache). The closer to the user, the cheaper the answer and the harder it is to invalidate. The biggest risk of the CDN is showing one user's data to another, so private data needs private or no-store. The risk of in-process memory is that each instance has its own copy.
How do you cache static assets and HTML differently? (Must know)
Files with a hash in the name, like /_next/static/..., get public, max-age=31536000, immutable, because a changed file gets a new name. HTML and API responses do not have hashed names, so I use short lifetimes or revalidation: for a public page s-maxage with stale-while-revalidate, and for private data private, no-store. Next.js sets these for static and dynamic routes itself (I saw private, no-cache, no-store, max-age=0, must-revalidate on a dynamic page). The details of ETag and 304 are in Browser Lesson 05.
Is compression always good? Gzip or brotli? (Trap)
Not always. Text (HTML, CSS, JS, JSON, SVG) compresses very well: my 100 kB JSON became about 14 kB with gzip and about 8 kB with brotli quality 4. Already-compressed files (JPEG, PNG, WebP, video) gain nothing and waste CPU. The level matters: brotli at quality 11 took about 180 ms for 100 kB, which is far too slow to do on every dynamic response, but it is fine for static files compressed once at build time. For dynamic responses I use gzip at a middle level or brotli at a low level. I must send Vary: Accept-Encoding so caches keep one copy per encoding, and I must not compress twice (Next.js compress: false when nginx or the CDN does it).
What does streaming with Suspense give you? What does it not give you? (Senior signal)
It gives an earlier first byte and an earlier first paint. In my test a page with a 1000 ms slow part had a TTFB of about 1.0 s without Suspense and about 10 to 20 ms with it. It does not make the slow work faster: the total time stayed at about 1 s. It also has costs: the status code is already 200 when the shell is sent, so a later notFound() cannot change it; a proxy that buffers will remove the benefit (the Next.js docs say to disable buffering, for example with X-Accel-Buffering: no); and a fallback with a different size than the content causes layout shift.
Where do you put <Suspense> boundaries? (Senior signal)
Around the slow parts, not around the whole page, so the shell and the fast parts show at once. Independent slow parts get their own boundary so they stream in whatever order they finish and do not block each other. I start independent requests in parallel instead of awaiting them one after another. The fallback should have the size of the real content. loading.js is a quick way to get one boundary around a whole route segment, but it shows a full-page skeleton, which is the coarse version.
How do you make images fast in a Next.js app? (Must know)
I use next/image with width and height so the space is reserved (no layout shift), sizes so small screens get small files, and lazy loading by default. For the one hero image that is the LCP element, I use preload (Next.js 16; priority is deprecated) or fetchPriority="high", and I do not preload the others. The default format is WebP, and I can enable AVIF at the cost of more cache storage. For static images I import them, so they get a hash and are cached forever. I check the result with LCP in the real-user data (Browser Lesson 09).
What do you do about fonts? (Must know)
I use next/font, which downloads the font at build time and serves it from my domain, so the browser makes no request to Google, and it helps avoid layout shift. I prefer a variable font, I limit the subsets and the weights, and I do not load fonts I do not use. Every font file is a request, and a late font can cause a flash of unstyled or invisible text.
The JavaScript bundle is too big. How do you investigate? (How would you debug)
First I look, I do not guess: the Next.js analyzer (next experimental-analyze for Turbopack in 16.1 and later, or @next/bundle-analyzer for webpack) shows which modules are large and who imports them. Then I move work to Server Components so it sends no JavaScript, I mark only the interactive leaf with 'use client', I lazy load things that are not needed first (a modal, a chart) with next/dynamic, and I replace heavy libraries or import only the function I need. I set a budget in CI so it does not grow back. I measure with the real effect on LCP and INP, not only kilobytes.
autocannon or k6? What is the difference? (Senior signal)
autocannon is a quick Node command: connections and duration, and a latency table. It is closed model by default, and its table shows p2.5, p50, p97.5 and p99, not p95. k6 uses JavaScript scripts, has scenarios and thresholds that fail the run, and its constant-arrival-rate executor is an open model. I use autocannon for a fast local check and k6 when I want a defined load profile and a pass or fail rule in CI. In both cases the load generator must not starve for CPU, or I measure the tool and not the server.
What is coordinated omission, and why does it matter? (Trap)
In a closed test, each connection waits for its response before sending the next request. When the server stalls, the test sends fewer requests during the stall, so the slow period is under-represented and the percentiles look too good. Real users do not wait politely. An open model, a fixed arrival rate, avoids this. I saw the effect: the N+1 endpoint at a fixed 400 requests per second had a p99 of 18.5 ms, but asked for 1,500 per second it reached only about 660 and the p99 was 739 ms, with thousands of dropped iterations. autocannon can correct for it with its -R rate option, and k6 does it by design with arrival-rate executors.
How do you read p50, p95 and p99, and which one do you put in a target? (Must know)
p50 is the typical request, p95 and p99 are the tail that unlucky users feel. I never use only the average. For a target I use p95 or p99, for example "p95 under 300 ms at 200 requests per second", because the tail adds up: if a page makes 20 calls and each is slow 1% of the time, about 18% of page views contain at least one slow call (if the calls are independent). A p99 much larger than p95 points to rare events like garbage collection, a lock, a cold cache or a connection being opened.
Your load test says 5,000 requests per second. Do you trust it? (Trap)
Not yet. I ask: was it a closed or an open test? Did the load tool run on the same machine and use the CPU? Was there a warm-up, and how many runs? Was the data the same size as production, and were the keys random or one hot row? What was p99, and were there errors? In my own test the first run had a much worse tail than the next two, and the tool and server shared 4 cores. A number is only useful with its conditions. I would repeat it three times, from another machine, at a fixed rate, and compare the ratio against the baseline.
How do you prove a performance change worked? (Senior signal)
I write the target and record a baseline with the conditions (data, machine, tool settings, commit). I change one thing. I repeat at least three runs, drop the warm-up, and report the median and the spread. I compare ratios and counts as well as milliseconds, such as pages read, queries per request and bytes sent, because counts hold across machines. I then confirm in production with real-user data and server percentiles. I also note what got worse, like write cost after adding an index or stale data after adding a cache.
You have one day. The API is slow under load. What are your first five moves? (Senior signal)
One, get the numbers: p95 and p99 per route and the query count per request, and find the worst route. Two, take the plan of the slowest query with EXPLAIN (ANALYZE, BUFFERS) and fix scans and missing indexes. Three, remove N+1 and make sure the pool is used and not starved. Four, only now add a cache, for the expensive and often-read data, with a TTL and a clear key. Five, run a load test at a fixed rate before and after, with three runs, and write the results down. I would leave streaming, image and bundle work for the browser side unless TTFB is already good.
Key takeaways
- Find the layer that owns the time before you change anything. Set a target. Change one thing. Measure again.
- Read plans for
Seq Scan,Rows Removed by Filter, row estimate gaps andBuffers. Counts of pages are stable across machines; milliseconds are not. - N+1 and connection setup cost round trips. Use a join or a batch, and a pool. On my machine the index, the join and the pool each changed throughput by a large factor, and the cache by a small one.
- Cache work that is expensive, read often and tolerates staleness. Always use a TTL and a full key. Fix the query before you cache it.
- Compress text, not images. Use a middle gzip or low brotli level for dynamic responses and the highest level only at build time. Hashed files get
immutable. - Streaming moves the first byte earlier; it does not make slow work faster. Put
<Suspense>around the slow part, and check for proxy buffering. - Size images, load fonts with
next/font, and look at the bundle with an analyzer before you cut. - Use percentiles, repeat runs, and prefer an open model for capacity questions. Say what the numbers do not show.
What's next
Go to Lesson 33 for Node.js internals: the event loop phases, the threadpool, streams and backpressure, which are the parts under many of the numbers in this lesson. For the Postgres side in more depth, return to Lesson 27, and for Redis patterns to Lesson 18.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Pagination, Filtering and Sorting · API Design
- Node.js Performance Basics: Event Loop Blocking, Streams, Workers · Backend in Depth
- Database Fundamentals for API Engineers: Indexes, Transactions, Isolation · Backend in Depth
Related series