In 01-04, when we listed REST's six constraints, we said cacheable was one of them and that we would come back to it. The moment has arrived. The Aroma Store API answers GET /v1/coffees in 40 milliseconds, which is perfectly good, except for one detail: the SPA asks for that catalogue every time a user opens the home page, and the catalogue changes once a day. Millions of identical requests, with the same response, consuming database, CPU and bandwidth to contribute absolutely nothing new.
The fastest request is the one that is never made. The second fastest is the one answered with a 150-byte 304 Not Modified. This lesson turns the theoretical constraint into concrete headers and into code inside the project: src/middleware/cache.js, Cache-Control calibrated resource by resource, ETag with conditional requests, and the reunion we have been promising since 03-05, when If-Match and the 412 close the optimistic concurrency circle. After that we will go beyond caching — compression, N+1, partial responses, asynchronous work — and finish with what should come first: measure before optimising.
Contents
- The levels of caching
Cache-Controlin depth- Aroma Store's caching policy
- Conditional validation:
ETagandLast-Modified - The complete 304 flow
If-Matchand the 412: closing the optimistic concurrency circle- Implementation:
src/middleware/cache.js Vary, content negotiation and CORS- Invalidation: the hard problem
- Server-side caching with Redis and cache-aside
- Stampede and locking
- Compression
- Connections, HTTP/2 and network latency
- The database: indexes, N+1 and slow queries
- Partial responses and pagination as a performance measure
- Asynchronous work with 202
- Measure before optimising
- The levels of caching
Between the user and the data there are several opportunities to do no work. Every level that hits saves everything to its right.
graph LR U[User] --> N[Browser cache<br/>private] N -->|miss| C[CDN / proxy<br/>shared] C -->|miss| A[Express API] A --> R[Application cache<br/>Redis] R -->|miss| D[Database] D --> P[SQL engine<br/>page cache]
| Level | Who controls it | Scope | What it saves |
|---|---|---|---|
| Browser | The Cache-Control we emit |
One user | Everything: there is not even a request |
| CDN / proxy | Cache-Control, s-maxage |
All users | Network and server |
| Application (Redis) | Our code | All instances | The database |
| Database | The engine | — | Disk |
Two concepts to distinguish carefully, because a security decision depends on them:
- Private cache: the browser's. It stores one user's responses.
- Shared cache: a CDN, a corporate proxy. It stores responses it serves to many users.
From which comes the most important rule in the whole lesson: any response that depends on who is asking must carry at least Cache-Control: private. If Marta's GET /v1/orders ends up in a CDN without that directive, the next person to ask for /v1/orders could receive Marta's orders. It is a data leak caused by a missing header, and it has happened in production at large companies more than once.
Cache-Control in depth
Cache-Control in depthThis is the header that governs everything. Its directives, grouped by what they do:
Who may store it
| Directive | Effect |
|---|---|
public |
Any cache, shared ones included |
private |
Only the browser's private cache |
no-store |
Nobody stores anything, anywhere |
For how long
| Directive | Effect |
|---|---|
max-age=N |
Valid for N seconds in any cache |
s-maxage=N |
The same, but for shared caches only; it takes precedence over max-age |
How it is revalidated
| Directive | Effect |
|---|---|
no-cache |
It may be stored, but it must be revalidated before every use |
must-revalidate |
Once it expires, serving it stale is forbidden |
immutable |
Never revalidate while it is fresh |
stale-while-revalidate=N |
Serve the stale copy for up to N s while refreshing in the background |
stale-if-error=N |
If the origin fails, serve the stale copy for up to N s |
no-cache does not mean "do not cache". It is the most repeated misreading in HTTP. no-cache means "store it, but ask me before using it". The one that prevents storing is no-store. The difference matters a great deal: with no-cache, a response that has not changed is resolved with a 150-byte 304; with no-store, it is transferred in full every time.
stale-while-revalidate is the most underrated directive. With max-age=60, stale-while-revalidate=300, for the first 60 seconds it is served from cache with no questions asked; between second 60 and second 360, the stale copy is served immediately and refreshed in the background. The user never waits. For a catalogue that changes once a day, that is exactly the desirable behaviour.
stale-if-error is free resilience: if your API returns a 503, the CDN keeps serving the last good copy instead of propagating the error. Combined with what we saw in 04-04, it turns a partial outage into an invisible degradation.
| Combination | Practical meaning | Example |
|---|---|---|
public, max-age=300 |
Anyone stores it for 5 minutes | A public catalogue |
public, max-age=60, s-maxage=600 |
Browser 1 min, CDN 10 min | A catalogue with a CDN |
private, max-age=0, must-revalidate |
Browser only, and always revalidating | A user's data |
no-store |
Never stored | Tokens, payments |
public, max-age=31536000, immutable |
A year with no questions | An image with a hash in its name |
public, max-age=60, stale-while-revalidate=300 |
No waiting on refresh | A very popular catalogue |
- Aroma Store's caching policy
| Resource | Cache-Control |
Reason |
|---|---|---|
GET /v1/coffees |
public, max-age=60, s-maxage=300, stale-while-revalidate=600 |
Public, changes little, heavily requested |
GET /v1/coffees/{id} |
public, max-age=300, stale-while-revalidate=600 |
Ditto, even more stable |
GET /v1/coffees/{id}/image |
public, max-age=31536000, immutable |
The name includes a hash of the content |
GET /v1/coffees/{id}/reviews |
public, max-age=60 |
Public, changes with every review |
GET /v1/orders |
private, no-cache |
Depends on the user; always revalidated |
GET /v1/orders/{id} |
private, no-cache |
Ditto |
GET /v1/customers/{id} |
private, no-cache |
Personal data |
GET /v1/carts/{id} |
no-store |
Changes constantly; no caching value |
POST /v1/sessions |
no-store |
It contains tokens |
| Any error response | no-store |
A cached 429 would block the user for longer |
GET /health |
no-store |
It must reflect the real state right now |
Four decisions that deserve justification:
no-cache on /v1/orders, not no-store. Marta's orders change little between visits. With no-cache, the browser keeps the copy and on the next visit sends an If-None-Match; if nothing has changed it receives a 304 with no body. All the traffic is saved while the freshness guarantee is kept, which is the best of both worlds. And private stops a CDN storing it.
no-store on POST /v1/sessions. The response contains the access token. Having it end up on the browser's disk or in a proxy is exactly what we do not want.
no-store on errors. A 429 cached for five minutes turns a one-minute limit into five. A cached 503 outlives the service's recovery.
A year and immutable on images. It only works because the file name contains a hash of the content (cof_001-a3f9.webp): if the image changes, the URL changes, so the old copy is never wrong. It is the fingerprinting pattern, and it is the only safe way to use such long expiry times.
Implemented as parameterisable middleware:
// src/middleware/cache.js (NEW file — part one)
/**
* Sets Cache-Control on the response. It is composed into each route's chain,
* because the policy depends on the resource.
*
* @param {object} options
* @param {boolean} options.isPublic May a shared cache store it?
* @param {number} options.maxAge Seconds of freshness for the browser.
* @param {number} [options.sharedMaxAge] Seconds for the CDN (s-maxage).
* @param {number} [options.revalidateInBackground] stale-while-revalidate.
* @param {boolean} [options.noStore] no-store: it is not even stored.
*/
export function cacheFor(options = {}) {
const {
isPublic = false,
maxAge = 0,
sharedMaxAge,
revalidateInBackground,
noStore = false,
} = options;
const directives = [];
if (noStore) {
directives.push('no-store');
} else {
directives.push(isPublic ? 'public' : 'private');
// max-age=0 is expressed as no-cache: "store it, but always revalidate".
if (maxAge === 0) directives.push('no-cache');
else directives.push(`max-age=${maxAge}`);
if (sharedMaxAge !== undefined) directives.push(`s-maxage=${sharedMaxAge}`);
if (revalidateInBackground) {
directives.push(`stale-while-revalidate=${revalidateInBackground}`);
}
}
const value = directives.join(', ');
return (req, res, next) => {
res.set('Cache-Control', value);
next();
};
}
/** Shortcuts with the policies already decided, so numbers are not repeated across the routes. */
export const publicCatalogueCache = cacheFor({
isPublic: true, maxAge: 60, sharedMaxAge: 300, revalidateInBackground: 600,
});
export const privateRevalidatedCache = cacheFor({ isPublic: false, maxAge: 0 });
export const noCache = cacheFor({ noStore: true });// src/routes/coffees.js (MODIFIED)
router.get('/', publicCatalogueCache, validate(coffeeQuerySchema, 'query'),
asyncHandler(controllers.coffees.list));
// src/routes/orders.js (MODIFIED)
router.get('/', authenticate, privateRevalidatedCache, validate(orderQuerySchema, 'query'),
asyncHandler(controllers.orders.list));
// src/routes/sessions.js (MODIFIED)
router.post('/', loginLimit, noCache, validate(loginSchema, 'body'),
asyncHandler(controllers.sessions.create));
- Conditional validation:
ETag and Last-Modified
ETag and Last-Modifiedmax-age answers "may I use my copy without asking?". When it expires, the question becomes "has it changed?", and that is where the validators come in.
ETag
An opaque identifier for a resource's version.
| Type | Syntax | Means |
|---|---|---|
| Strong | ETag: "a3f9c2e1" |
Byte-for-byte identical |
| Weak | ETag: W/"a3f9c2e1" |
Semantically equivalent |
A weak ETag works for the 304 but not for If-Match on writes nor for range requests, precisely because it does not guarantee exact equality. Since Aroma Store is going to use If-Match for optimistic concurrency, we need strong ETags.
Two ways of generating one:
| Method | How | Advantage | Drawback |
|---|---|---|---|
| Hash of the body | SHA-1/SHA-256 of the serialised JSON | Universal, requires no model | You have to generate the whole response |
The version field |
"v7" from the column that already exists |
Cheap: known before serialising | Only for resources that have a version |
Aroma Store uses both: the version field from 03-05 for individual resources that have it, and the hash for collections and everything else.
// src/middleware/cache.js (part two)
import crypto from 'node:crypto';
/** A strong ETag from the serialised body. */
export function etagFromBody(body) {
const text = typeof body === 'string' ? body : JSON.stringify(body);
const hash = crypto.createHash('sha256').update(text).digest('base64url').slice(0, 27);
return `"${hash}"`; // the quotes are MANDATORY in the syntax
}
/** An ETag from the version field that coffees and orders already carry (03-05). */
export function etagFromVersion(resource) {
return `"v${resource.version}"`;
}The quotes are not decorative: they are part of the header's syntax. An ETag: a3f9 with no quotes is invalid and many intermediaries ignore it, so caching stops working without giving any error at all.
Last-Modified
An HTTP date:
It is weaker than the ETag for two reasons: it has a resolution of one second — two changes within the same second are indistinguishable — and many resources have no reliable modification date.
ETag |
Last-Modified |
|
|---|---|---|
| Precision | Total | 1 second |
| Request header | If-None-Match |
If-Modified-Since |
| Cost of computing it | A hash or a version | Reading a date |
| Usable for conditional writes | Yes (If-Match) |
Not reliably |
| Precedence when both are present | ETag wins |
— |
Aroma Store emits both when it has an updatedAt, but the ETag is the main mechanism.
- The complete 304 flow
First request. The client has nothing:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Last-Modified: Sun, 02 Aug 2026 09:14:22 GMT
Vary: Accept, Accept-Language, Origin
Content-Length: 284
{"id":"cof_001","name":"Ethiopia Yirgacheffe","origin":"Ethiopia","roast":"light",
"priceEuros":14.50,"stock":120,"version":7,"_links":{"self":{"href":"/v1/coffees/cof_001"}}}Within the 300 seconds: the browser serves its copy without asking. Zero requests, zero latency.
After the 300 seconds: the copy has expired, but the browser has the validator and asks:
GET /v1/coffees/cof_001 HTTP/1.1
Host: api.aromastore.example
If-None-Match: "v7"
If-Modified-Since: Sun, 02 Aug 2026 09:14:22 GMTIf nothing has changed:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Vary: Accept, Accept-Language, OriginNo body. 284 bytes become about 150 of headers, and the browser renews its copy's freshness for another 300 seconds.
If the coffee changed (version went to 8):
HTTP/1.1 200 OK
ETag: "v8"
Content-Type: application/json
{"id":"cof_001", ..., "priceEuros":15.20, "version":8, ...}sequenceDiagram participant N as Browser participant API as API N->>API: GET /v1/coffees/cof_001 API->>N: 200 + ETag "v7" + max-age=300 Note over N: Within the 300 s: served from cache, no request N->>API: GET with If-None-Match "v7" (now expired) API->>API: Compares "v7" with the current ETag API->>N: 304 Not Modified, no body Note over N: Freshness renewed for another 300 s N->>API: GET with If-None-Match "v7" (after a change) API->>N: 200 + ETag "v8" + a new body
Three details about the 304 that are often got wrong:
- It carries no body. Sending one is a protocol error.
- It does carry the caching headers (
Cache-Control,ETag,Vary): they are what renews the stored copy's validity. If-None-Matchaccepts several ETags separated by commas, plus the*wildcard meaning "if any representation exists".
If-Match and the 412: closing the optimistic concurrency circle
If-Match and the 412: closing the optimistic concurrency circleHere is the reunion we announced in 03-05. If-None-Match is for reading; If-Match is for writing safely.
The problem: the lost update
sequenceDiagram participant E1 as Employee 1 participant API as API participant E2 as Employee 2 E1->>API: GET /v1/coffees/cof_001 → price 14.50, version 7 E2->>API: GET /v1/coffees/cof_001 → price 14.50, version 7 E1->>API: PUT price 15.20 API->>E1: 200, version 8 E2->>API: PUT price 13.90 (with version 7 data) API->>E2: 200, version 9 Note over API: Employee 1s change has vanished with no warning
Nobody has seen an error. The price 15.20 never existed.
The solution with If-Match
PUT /v1/coffees/cof_001 HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer <employee token>
Content-Type: application/json
If-Match: "v7"
{"name":"Ethiopia Yirgacheffe","origin":"Ethiopia","roast":"light",
"priceEuros":13.90,"stock":120,"tastingNotes":"Jasmine, bergamot"}If the current version is no longer 7:
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "v8"
Cache-Control: no-store
{
"error": {
"code": "version_conflict",
"message": "The resource has changed since you fetched it. Read it again and retry.",
"details": []
}
}The ETag: "v8" on the error response is a valuable design detail: it tells the client what the current version is, so it can re-read, show the conflict to the user and retry with no extra request.
Why the header is better than the body's version field
In 03-05 we implemented optimistic concurrency with version inside the JSON. It works, but If-Match is better for five reasons:
version in the body |
If-Match in the header |
|
|---|---|---|
| Standard | Our own convention | HTTP (RFC 9110): any client understands it |
| Separation | Mixes metadata with data | The metadata travels as metadata |
Works with DELETE |
No: DELETE has no body |
Yes |
| Intermediaries understand it | No | Yes: proxies and gateways |
| Status code | An ad hoc 409 |
412, which means exactly that |
Reuses the read ETag |
No | Yes: the same value it already received |
The last row is the conceptual key: the client already received the ETag when it read the resource. It does not need to understand what version is, nor extract it from the body: it returns the opaque tag it was given. That is exactly HTTP's design.
And there is an important contract decision: whether or not to require If-Match.
| Policy | Behaviour without If-Match |
When |
|---|---|---|
| Optional | The write proceeds (last one wins) | Rarely contended resources |
| Mandatory | 428 Precondition Required |
Critical resources: price, stock |
Aroma Store requires it on PUT /v1/coffees/{id} and on PATCH /v1/orders/{id}, because a price or a stock level being wrongly overwritten has real commercial consequences. That forces us to add a new code to the catalogue: precondition_required (428). We note it explicitly, as the project's rule demands — the catalogue only grows — and it has to be documented in openapi.yaml.
| Situation | Code | Catalogue status |
|---|---|---|
If-Match missing where it is mandatory |
428 |
precondition_required — NEW |
If-Match does not match |
412 |
version_conflict — already existed |
If-None-Match matches on a GET |
304 |
No body, no error |
If-None-Match: * on a POST that would create a duplicate |
412 |
conflict |
// src/middleware/cache.js (part three)
import { errors } from '../errors/api-error.js';
/**
* Requires and checks If-Match on writes.
* It is composed into the route BEFORE the controller; the service receives the
* expected version already extracted and does not have to know anything about HTTP.
*/
export function requireIfMatch(req, res, next) {
const ifMatch = req.get('If-Match');
if (!ifMatch) {
return next(
errors.preconditionRequired(
'precondition_required',
'This operation requires the If-Match header with the ETag obtained when reading the resource.'
)
);
}
if (ifMatch.trim() === '*') {
req.expectedVersion = null; // '*' = "any version exists": any will do
return next();
}
// A list is accepted: If-Match: "v7", "v8"
const versions = ifMatch
.split(',')
.map((e) => e.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
.map((e) => (e.startsWith('v') ? Number(e.slice(1)) : Number.NaN))
.filter((n) => Number.isInteger(n));
if (versions.length === 0) {
return next(errors.invalidData('invalid_data', 'The If-Match header is not valid.'));
}
req.acceptedVersions = versions;
return next();
}// src/errors/api-error.js (MODIFIED)
export const errors = {
// ... the rest
preconditionRequired: (code, message) => new ApiError(428, code, message),
preconditionFailed: (code, message) => new ApiError(412, code, message),
};// src/services/coffees.js (MODIFIED — fragment)
export async function updateCoffee(id, data, acceptedVersions) {
const current = await coffeeRepository.findById(id);
if (!current) throw errors.notFound('coffee_not_found', `Coffee ${id} does not exist.`);
// null = If-Match: * → it merely has to exist.
if (acceptedVersions && !acceptedVersions.includes(current.version)) {
throw errors.preconditionFailed(
'version_conflict',
'The resource has changed since you fetched it. Read it again and retry.'
);
}
// The write is still conditional in SQL (03-05): another transaction can slip in
// between the check above and this UPDATE. The WHERE version = ?
// is what makes the operation genuinely atomic.
const rows = coffeeRepository.updateIfVersion(id, data, current.version);
if (rows === 0) {
throw errors.preconditionFailed('version_conflict', 'Version conflict on write.');
}
return coffeeRepository.findById(id);
}The comment in the middle is the subtle point: checking the version in the service is not enough; there is a window between the read and the write. The real guarantee comes from the UPDATE's WHERE version = ?, which is atomic. If-Match contributes the correct HTTP semantics and the early error; correctness still comes from the database.
// src/routes/coffees.js (MODIFIED)
router.put(
'/:id',
authenticate,
requireRole('employee', 'administrator'),
requireIfMatch, // ← 428 if it is missing
validate(replaceCoffeeSchema, 'body'),
asyncHandler(controllers.coffees.replace)
);And Accept-Patch, which we have been emitting since 02-05, goes hand in hand with this: it tells the client which patch format the resource accepts, just as ETag tells it which version it has.
- Implementation:
src/middleware/cache.js
src/middleware/cache.jsWhat is missing is the piece that answers the 304 automatically. The strategy: wrap res.json to compute the ETag just before sending and compare it with If-None-Match.
// src/middleware/cache.js (part four)
/**
* Computes the response's ETag and answers 304 if the client already has it.
*
* It is registered globally before the routes: it wraps res.json to
* intercept the body just before serialising it.
*/
export function conditionalEtag(req, res, next) {
// It only makes sense on reads.
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const originalJson = res.json.bind(res);
res.json = function (body) {
// 1. Errors never get an ETag: they do not represent the resource.
if (res.statusCode >= 400) return originalJson(body);
// 2. If the controller already set an ETag (the version one, for instance), it is respected.
const etag = res.get('ETag') ?? etagFromBody(body);
res.set('ETag', etag);
// 3. Comparison with what the client says it has.
const ifNoneMatch = req.get('If-None-Match');
if (ifNoneMatch) {
const matches =
ifNoneMatch.trim() === '*' ||
ifNoneMatch
.split(',')
.map((e) => e.trim())
.some((e) => e === etag || e === `W/${etag}`); // W/ for weak ETags
if (matches) {
// 304: no body. The entity headers that are no longer relevant are removed.
res.removeHeader('Content-Type');
res.removeHeader('Content-Length');
return res.status(304).end();
}
}
return originalJson(body);
};
return next();
}// src/app.js (extract after 04-06)
app.disable('x-powered-by'); // 1
app.set('trust proxy', 1);
app.use(assignTraceId); // 2
app.use(securityHeaders); // 3 helmet
app.use(cors(corsOptions)); // 4
// (5) structured logging → 04-07
app.use(globalLimit); // 6
app.use(compression(compressionOptions)); // 7 ← NEW (section 12)
app.use(express.json({ limit: '100kb', /* ... */ })); // 8
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/health', ...); // 9
app.use(conditionalEtag); // 10 ← NEW
app.use('/v1', v1Routes); // 11
app.use(notFoundHandler); // 12
app.use(errorHandler); // 13Why conditionalEtag at position 10, right before the routes. It has to wrap res.json before any controller calls it, and at the same time be after everything that can respond on its own (rate limiting, parser), because we do not want to give an ETag to those responses. And why compression at position 7, earlier: compression must wrap the writing of the response as early as possible, and it must not try to compress a 304 that has no body.
A practical note: Express ships its own automatic ETag (app.set('etag', ...)), but it is weak by default, which makes it useless for If-Match. Our middleware replaces it with strong ETags and with explicit control over when they are emitted.
Vary, content negotiation and CORS
Vary, content negotiation and CORSVary declares which request headers the response depends on. It is what stops a cache serving the wrong response.
Header in Vary |
Why | Lesson |
|---|---|---|
Accept |
The representation may differ according to the type requested | 02-05 |
Accept-Language |
The tasting notes are translated | 02-05 |
Origin |
The response carries a reflected Access-Control-Allow-Origin |
04-05 |
Authorization |
Not included: private is used instead |
Below |
The Authorization case deserves an explanation because it looks like the obvious answer and is not. Setting Vary: Authorization would make the cache store one entry per distinct token: since tokens change every 15 minutes, the hit rate would be practically zero and the CDN's memory consumption enormous. The correct way to protect personalised content is Cache-Control: private, which directly prevents a shared cache storing it.
And the cost warning: each header in Vary multiplies the stored variants. Vary: Accept, Accept-Language, Origin with 2 types, 3 languages and 3 origins is 18 copies of the same resource. Declare only what really changes the response.
- Invalidation: the hard problem
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
The difficulty is real: you have distributed copies of your data across browsers and CDNs all over the world, and now the price of cof_001 has changed.
| Strategy | How | Advantage | Drawback |
|---|---|---|---|
| Short TTL | max-age=60 and wait |
Trivial | Up to 60 s of stale data |
| Explicit purge | Call the CDN's API on change | Immediate | Coupling; the browser's cache cannot be purged |
| Versioned key | The URL includes a hash | Perfect, no purging | Only for resources with a controllable URL |
| Revalidation | no-cache + ETag |
Always fresh | One request per use (though a cheap one) |
| Event-driven | A webhook triggers the purge | Precise and decoupled | Requires event infrastructure |
The browser's cache cannot be purged. Once you have sent max-age=3600, that browser will serve the copy for an hour and there is nothing to be done. That is why the browser TTLs are short (60 s) and the CDN ones long (300 s with s-maxage): the CDN can be purged.
// src/services/cache-invalidation.js (NEW file)
import { redis } from '../config/redis.js';
import { environment } from '../config/environment.js';
/**
* Invalidates a resource at every level we control.
* The browser's cache CANNOT be invalidated: hence its short TTL.
*/
export async function invalidateCoffee(coffeeId) {
// 1. Application cache (Redis): a direct delete.
await redis.del(`aroma:cache:coffee:${coffeeId}`);
// 2. Lists depend on the item: they are invalidated by tag pattern.
await redis.del('aroma:cache:coffees:list');
// 3. CDN: purge by tag (surrogate key), not URL by URL.
if (environment.CDN_PURGE_URL) {
await fetch(environment.CDN_PURGE_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${environment.CDN_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tags: [`coffee-${coffeeId}`, 'catalogue'] }),
signal: AbortSignal.timeout(3000),
}).catch((e) => {
// A failed purge must NOT break the write. It is logged and the TTL
// will eventually resolve it: an acceptable degradation.
console.error('Failed to purge the CDN:', e.message);
});
}
}Purge tags (surrogate keys) are the mechanism that makes this manageable. Each response declares which groups it belongs to:
And when cof_001 changes, the purge goes by tag rather than having to enumerate the dozens of affected URLs (the detail page, the list, the list filtered by origin, the one sorted by price…). Without tags, invalidating collections is practically impossible to do well.
Why event-driven invalidation fits with webhooks. Aroma Store already emits signed events towards SwiftShip (order.paid, order.shipped). The same mechanism works for the cache: when the catalogue service changes a price, it publishes coffee.updated and whoever is subscribed — the CDN, another instance, the back office — purges its own copy. The advantage is decoupling: the service doing the writing does not need to know every cache that exists, only to announce that something changed.
- Server-side caching with Redis and cache-aside
HTTP caching avoids requests. Server-side caching avoids database queries for the requests that do arrive.
What is worth caching in Aroma Store:
| Data | TTL | Why |
|---|---|---|
| The complete catalogue (first page) | 60 s | The most frequent request by a long way |
| A coffee's detail by id | 300 s | Very repeated, changes little |
A collection's total |
300 s | The COUNT(*) is expensive and tolerates being slightly stale |
| A coffee's average rating | 600 s | An expensive aggregation, precision is not critical |
| A customer's orders | No | Personal, changes, rarely repeated |
| Stock | No | It must be exact: caching it causes overselling |
The last two rows are as important as the first ones: knowing what not to cache avoids incidents. Caching stock for 60 seconds means selling coffee that does not exist.
The cache-aside pattern (or lazy loading):
// src/services/cache.js (NEW file)
import { redis } from '../config/redis.js';
/**
* Cache-aside: look in the cache; if it is not there, compute, store and return.
*
* @param {string} key The complete key, with its namespace prefix.
* @param {number} ttl Lifetime in seconds.
* @param {Function} compute Function that obtains the real data.
*/
export async function withCache(key, ttl, compute) {
try {
// 1. Is it in the cache? (a hit)
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
} catch (e) {
// 2. If Redis fails, the request is NOT broken: we carry on against the database.
// The cache is an optimisation, never a hard dependency.
console.error('Cache unavailable, querying the origin:', e.message);
}
// 3. Cache miss: it is computed.
const value = await compute();
// 4. It is stored without waiting and without breaking if it fails.
redis.set(key, JSON.stringify(value), 'EX', ttl).catch(() => {});
return value;
}// src/services/coffees.js (MODIFIED — fragment)
export async function getCoffee(id) {
return withCache(`aroma:cache:coffee:${id}`, 300, async () => {
const row = await coffeeRepository.findById(id);
if (!row) throw errors.notFound('coffee_not_found', `Coffee ${id} does not exist.`);
return mappers.coffeeToRepresentation(row);
});
}Four rules to stay out of trouble:
- Cache the already-mapped result, not the raw row. That way the object in Redis contains no internal columns: if one day somebody dumps it for debugging, it does not leak
price_centsoractive. - The cache is never a hard dependency. If Redis goes down, the API is slower, it does not fail.
- Namespace your keys (
aroma:cache:), kept separate from the rate limiting'saroma:rl:from 04-04. - Invalidate on write, in the same operation that modifies the data.
An important warning: do not cache error objects or exceptions. In the example, if the coffee does not exist an exception is thrown before anything is stored; if the null were cached, a newly created coffee would take five minutes to appear.
- Stampede and locking
The scenario: cof_001 is the most requested coffee and its cache entry expires at 12:00:00. At that instant there are 500 requests in flight. All 500 miss the cache, all 500 query the database and all 500 write the same value.
That is the cache stampede (or thundering herd), and it is especially cruel because it happens precisely on the most popular resources, that is, at the worst possible moment.
Three defences, from least to most effective:
TTL with jitter. Instead of exactly 300 seconds, a random value between 270 and 330. It spreads the expiries out and stops thousands of keys expiring at once. It is one line of code:
Locking. Only one process recomputes; the rest wait briefly and retry the cache:
// src/services/cache.js (part two)
/**
* Cache-aside with a lock: stops N simultaneous requests recomputing the same thing.
*/
export async function withLockedCache(key, ttl, compute) {
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
const lockKey = `${key}:lock`;
// SET NX: only succeeds if the key did NOT exist. It is an atomic operation,
// so exactly one process gets the lock.
// EX 10: the lock expires on its own, so a crashed process does not hold it for ever.
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (acquired) {
try {
const value = await compute();
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
await redis.del(lockKey); // always released, whether it failed or not
}
}
// Another process is recomputing: wait a little and look again.
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry !== null) return JSON.parse(retry);
// If it is still not there, compute anyway: better to duplicate work than to fail.
return compute();
}Early refresh. Store the expiry instant alongside the value and refresh it before it expires, probabilistically. There is never a moment when the key does not exist. It is what stale-while-revalidate does in the HTTP cache, applied to the server.
- Compression
// src/app.js (fragment)
import compression from 'compression';
const compressionOptions = {
// Below 1 kB, compressing costs more CPU than it saves on the network.
threshold: 1024,
filter(req, res) {
// Allows it to be turned off per request, useful for debugging.
if (req.get('Aroma-No-Compression')) return false;
// Images are already compressed: re-compressing is wasted time.
const type = res.get('Content-Type') ?? '';
if (type.startsWith('image/') || type.startsWith('video/')) return false;
return compression.filter(req, res);
},
level: 6, // 1 = fast, 9 = maximum. 6 is the usual balance.
};
app.use(compression(compressionOptions)); // position 7The saving on JSON is considerable because it is text with a great deal of key repetition:
| Response | Uncompressed | gzip | Brotli |
|---|---|---|---|
GET /v1/coffees (20 items) |
8.4 kB | 1.9 kB | 1.6 kB |
GET /v1/coffees/cof_001 |
284 B | (not compressed: below the threshold) | — |
GET /v1/orders (20 with expand) |
42 kB | 6.1 kB | 5.2 kB |
When not to compress:
- Small responses (below about 1 kB): the overhead exceeds the saving.
- Already-compressed content: images, video, PDF, ZIP.
- When the CPU is the bottleneck and the network has plenty of headroom.
- Historically, responses with secrets alongside attacker-controlled data, because of the BREACH/CRIME attacks. With
Authorization: Bearerinstead of cookies the practical risk is much lower, but it is worth knowing about.
Brotli compresses better than gzip and every modern browser supports it; most CDNs apply it on their own. If you have a CDN, the usual approach is to let it compress and save that CPU in your process.
- Connections, HTTP/2 and network latency
Revisiting 01-03 from a performance perspective:
| Mechanism | What it saves | Typical gain |
|---|---|---|
| Keep-alive | A TCP + TLS handshake per request | 100–300 ms per request avoided |
| HTTP/2 multiplexing | The browser's queue (6 connections) | A great deal with many parallel requests |
| HTTP/2 header compression | Repeating Authorization on every request |
~500 bytes per request |
| HTTP/3 / QUIC | Head-of-line blocking from packet loss | Noticeable on mobile networks |
| A CDN near the user | Physical distance | 50–200 ms |
A reminder from 04-04: Node's keepAliveTimeout must be greater than the load balancer's, or sporadic, impossible-to-reproduce 502s will appear.
And the perspective that orders the priorities: on a typical mobile request, network latency dominates everything else. If your API answers in 40 ms and the user is 150 ms away, optimising the SQL query down to 30 ms improves the total by 5%. Cutting five requests down to one (04-01) or serving from a CDN improves it far more. The most profitable optimisation is almost never on the server.
- The database: indexes, N+1 and slow queries
Indexes. Every filter in the contract needs its own:
-- migrations/004-performance-indexes.sql
CREATE INDEX IF NOT EXISTS idx_coffees_origin ON coffees(origin);
CREATE INDEX IF NOT EXISTS idx_coffees_roast ON coffees(roast);
CREATE INDEX IF NOT EXISTS idx_coffees_price ON coffees(price_cents);
-- Composite: it covers the filter by customer AND the sort by date at once.
CREATE INDEX IF NOT EXISTS idx_orders_customer_date ON orders(customer_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reviews_coffee ON reviews(coffee_id);The order of the columns in a composite index matters: (customer_id, created_at) works for filtering by customer and sorting by date, but not for sorting by date without filtering by customer. And indexes are not free: they speed up reads and slow down writes, because they have to be maintained.
How to verify they are being used:
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 'cus_842' ORDER BY created_at DESC LIMIT 20;
-- Good: SEARCH orders USING INDEX idx_orders_customer_date (customer_id=?)
-- Bad: SCAN orders ← it walks the entire tableThe N+1, now in the database (in 04-01 we saw it over HTTP):
// ❌ 1 query + N queries: with 20 orders and 3 items each, 61 queries.
const orders = orderRepository.findAll(criteria);
for (const order of orders) {
order.items = itemRepository.findByOrder(order.id);
}
// ✅ 2 queries, always, whatever the number of orders.
const orders = orderRepository.findAll(criteria);
const ids = orders.map((o) => o.id);
const placeholders = ids.map(() => '?').join(','); // ?,?,? according to the real count
const allItems = db
.prepare(`SELECT * FROM order_items WHERE order_id IN (${placeholders})`)
.all(...ids);
// They are grouped in memory: O(n), far cheaper than N round trips to the database.
const byOrder = new Map();
for (const item of allItems) {
if (!byOrder.has(item.order_id)) byOrder.set(item.order_id, []);
byOrder.get(item.order_id).push(item);
}
for (const order of orders) order.items = byOrder.get(order.id) ?? [];A security note about the IN: the ? placeholders are generated from the number of items, and the values go as parameters. The ids are never interpolated into the SQL (04-02). And the size of the list has to be bounded, because engines have a parameter limit; with a maximum limit of 100 we are well below it.
Slow queries. A simple log detects what tests never see:
// src/config/database.js (MODIFIED)
const THRESHOLD_MS = 50;
export function instrumentedQuery(sql, parameters, execute) {
const start = performance.now();
const result = execute();
const duration = performance.now() - start;
if (duration > THRESHOLD_MS) {
// The SQL is a fixed template with no user data: safe to log.
// The PARAMETERS are not logged: they may contain personal data (04-02).
logger.warn({ sql, durationMs: Math.round(duration) }, 'slow query');
}
return result;
}The comment marks a GDPR decision: the SQL template is logged, the values are not.
- Partial responses and pagination as a performance measure
Two mechanisms from module 2 that now read as first-order optimisations.
fields (02-05): if Aroma Mobile's list only shows name, price and roast, asking for the whole object wastes bandwidth and serialisation.
| Request | Size | Reduction |
|---|---|---|
GET /v1/coffees?limit=20 |
8.4 kB | — |
GET /v1/coffees?limit=20&fields=id,name,priceEuros,roast |
2.1 kB | 75% |
Pagination (02-06): besides being mandatory by design (04-01), it is the most effective defence against degradation as things grow. And the cursor on /orders is not a whim: with offset=100000, the engine has to locate and discard 100,000 rows before returning 20. With a cursor, it jumps straight in by index.
| Method | Cost with 1M rows, page 5,000 | Consistency with writes |
|---|---|---|
offset=100000 |
Linear: discards 100,000 rows | May duplicate or skip rows |
cursor=eyJmZWNoYSI6... |
Constant: jumps by index | Stable |
- Asynchronous work with 202
Some operations do not fit inside a single request's cycle. Generating the PDF invoice for an order with twenty items, or the data export from the 04-02 exercise, take seconds.
Keeping the connection open is a bad idea: it exhausts the processes, clashes with the load balancer's timeouts and means a network failure forces the whole job to be repeated. The correct pattern:
HTTP/1.1 202 Accepted
Location: /v1/tasks/tsk_9f3a
Cache-Control: no-store
Content-Type: application/json
{
"id": "tsk_9f3a",
"status": "in_progress",
"resource": "/v1/orders/ord_5001/invoice",
"_links": { "self": { "href": "/v1/tasks/tsk_9f3a" } }
}The client polls the task resource:
HTTP/1.1 200 OK
Retry-After: 2
Content-Type: application/json
{ "id": "tsk_9f3a", "status": "in_progress", "progress": 0.4 }And when it finishes:
Four design details:
202means "accepted, not done yet", and it is different from201("created"). The decision tree from 02-04 covered it.- The task is a resource, with its URI, its representation and its
_links. It is not a separate mechanism. Retry-Afteron the poll stops the client asking every 50 ms. It is the same mechanism as 04-04, applied to something that is not an error.303 See Otheron completion redirects to the final resource. And the catalogue'soperation_in_progresscode covers the case of asking for the invoice while it is still being generated.
For the internal back office, instead of polling we use SSE, which is already part of Aroma Store's architecture: the server pushes the status change when it happens.
- Measure before optimising
Everything above is useless — or counterproductive — if it is applied blindly. The correct order is always: measure, find the real bottleneck, fix that, measure again.
Why the average deceives
Ten requests: nine at 20 ms and one at 2,000 ms.
| Statistic | Value | What it says |
|---|---|---|
| Mean | 218 ms | A number no user has ever experienced |
| p50 (median) | 20 ms | Half of them are this fast |
| p95 | 2,000 ms | 5 requests in every 100 are terrible |
| p99 | 2,000 ms | Ditto |
The mean is an average of two different populations and describes neither. Percentiles are what you have to look at, and the p99 is the most important, because a page making 10 requests has roughly a 10% chance that at least one falls in the p99. Your p99 is the everyday experience of your most active users.
The latency budget
It is decided before optimising, and it becomes the criterion for knowing when to stop:
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
GET /v1/coffees |
30 ms | 80 ms | 150 ms |
GET /v1/coffees/{id} |
15 ms | 40 ms | 80 ms |
POST /v1/orders |
80 ms | 200 ms | 400 ms |
POST /v1/sessions |
150 ms | 300 ms | 500 ms |
The login is deliberately the slowest: bcrypt with cost 12 takes ~100 ms on purpose (03-06). That is not a performance problem to be fixed, it is a defence working. Without the written budget, somebody will eventually "optimise" it by lowering bcrypt's cost.
Load testing with autocannon
# 50 concurrent connections for 30 seconds.
npx autocannon -c 50 -d 30 http://localhost:3000/v1/coffees
# With authentication and a route that is not cached.
npx autocannon -c 20 -d 30 \
-H "Authorization: Bearer $TOKEN" \
http://localhost:3000/v1/orders
# Check the ETag's real effect: the second run should be far faster.
npx autocannon -c 50 -d 20 -H 'If-None-Match: "v7"' \
http://localhost:3000/v1/coffees/cof_001Typical output:
┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬────────┐ │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ ├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼────────┤ │ Latency │ 8 ms │ 14 ms│ 46 ms │ 89 ms│ 17.2 ms │ 12.4 ms │ 210 ms │ └─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴────────┘ 2894 requests/sec, 1.2 MB/sec read
How to read it without deceiving yourself:
- Look at the p99 (the 99% column), not the average.
- Always compare against a baseline taken before the change. An absolute number on its own says nothing.
- A single machine is not production: there is no real network latency, no CDN, no other instances and no real data.
- With little data, everything is fast. Test with a realistic volume: a 100-row table fits in memory and hides the absence of indexes.
- Watch CPU and memory during the test. If the CPU is at 100%, the bottleneck is yours; if it is at 20% and latency rises, the bottleneck is in the database or in a third party.
The order of optimisation
- Measure and locate the genuinely slow endpoint, with production data.
- Can the request be avoided? HTTP caching. It is the biggest possible gain.
- Can the number of requests be reduced?
expand(04-01). - Can the query be avoided? Server-side caching.
- Can the query be made cheaper? Indexes, N+1,
fields. - Can it be done later?
202and asynchronous work. - Measure again and check against the budget.
And a final warning: caching adds complexity and a new class of bugs — stale data, incomplete invalidation, inconsistencies between levels. If an endpoint answers in 15 ms and is called ten times a day, caching it contributes nothing and adds one more way to fail.
Common Mistakes and Tips
Believing no-cache means "do not cache". It means "always revalidate". The one that prevents storing is no-store.
Forgetting private on personalised responses. A CDN can serve one user's orders to another. It is the most serious data leak in this lesson.
Caching error responses. A 429 cached for five minutes turns a one-minute block into five.
Using weak ETags for If-Match. They do not guarantee exact equality and the standard does not allow it. Express's automatic ETag is weak.
Returning a body on a 304. It is a protocol error; it also cancels out the saving.
Forgetting Vary. With content negotiation or CORS, it causes intermittent, impossible-to-reproduce crossed responses.
Caching stock. It causes overselling. Some data must be exact at all times.
Setting long TTLs in the browser. They cannot be purged. Short in the browser, long in the CDN with s-maxage.
Optimising without measuring. Most of the time is lost where nobody looks, and the work goes where things were already fast.
Tip: start with the catalogue. It is 80% of the traffic and the most cacheable. Solved well, it solves almost everything.
Tip: make the cache observable. Without hit and miss metrics you do not know whether it works. That is the subject of 04-07.
Tip: test the 304 explicitly. It is easy for the middleware to stop working in a refactor with nobody noticing: it only shows up on the bandwidth bill.
Exercises
Exercise 1: deciding the caching policy
For each resource, decide the complete Cache-Control and whether you would emit an ETag. Justify each choice:
GET /v1/coffees?origin=Ethiopia— a filtered, public catalogue.GET /v1/customers/cus_842/preferences— the authenticated user's preferences.GET /v1/coffees/cof_001/image— an image whose name includes a hash.POST /v1/sessions— it returns tokens.GET /v1/orders/ord_5001— the customer's own order, consulted frequently.GET /v1/coffees/cof_001/reviews?sort=-createdAt— public reviews.
Exercise 2: implementing If-Match on DELETE
Implement conditional deletion of a review: DELETE /v1/reviews/{id} must require If-Match, return 204 if the version matches, 412 version_conflict if it does not, 428 precondition_required if the header is missing and 404 if it does not exist. Write the route, the service and an integration test for the four cases.
Exercise 3: diagnosing a performance problem
GET /v1/orders?limit=20&expand=items.coffee has a p50 of 45 ms and a p99 of 1,800 ms. The server's CPU sits at 25% during the load test. List at least four possible causes, ordered by likelihood, and say how you would verify and fix each one.
Solutions
Solution 1
| No. | Cache-Control |
ETag |
Justification |
|---|---|---|---|
| 1 | public, max-age=60, s-maxage=300, stale-while-revalidate=600 |
Yes (hash) | Public and stable. It needs Vary: Accept-Language because the tasting notes are translated. The CDN can hold it longer because it can be purged |
| 2 | private, no-cache |
Yes (hash or version) |
It depends on the user: private is mandatory. no-cache allows the 304, which is the best option for stable personal data |
| 3 | public, max-age=31536000, immutable |
Not needed | The hash in the name guarantees that the URL changes if the content changes. With immutable, the browser does not even revalidate |
| 4 | no-store |
No | It contains tokens. It must not be left on any disk or proxy |
| 5 | private, no-cache |
Yes (version) |
Personal, consulted often and changes little: the 304 saves a lot. The version ETag also works for If-Match |
| 6 | public, max-age=60 |
Yes (hash) | Public. A short TTL because a new review should appear soon. Vary: Accept-Language |
Solution 2
// src/routes/reviews.js
router.delete(
'/:id',
authenticate,
requireRole('customer', 'employee', 'administrator'),
requireIfMatch, // 428 if it is missing
asyncHandler(controllers.reviews.remove)
);// src/controllers/reviews.js
export async function remove(req, res) {
await services.reviews.remove(req.params.id, req.acceptedVersions, req.user);
res.status(204).end(); // 204: no body
}// src/services/reviews.js
export async function remove(id, acceptedVersions, requester) {
const review = await reviewRepository.findById(id);
// 404 also when it belongs to somebody else: we do not confirm existence (04-02).
if (!review) throw errors.notFound('review_not_found', `Review ${id} does not exist.`);
const isOwner = review.customerId === requester.id;
const isStaff = ['employee', 'administrator'].includes(requester.role);
if (!isOwner && !isStaff) {
throw errors.notFound('review_not_found', `Review ${id} does not exist.`);
}
if (acceptedVersions && !acceptedVersions.includes(review.version)) {
throw errors.preconditionFailed(
'version_conflict',
'The review has changed since you fetched it. Read it again and retry.'
);
}
// An atomic conditional delete: if another transaction slipped in, rows will be 0.
const rows = reviewRepository.removeIfVersion(id, review.version);
if (rows === 0) {
throw errors.preconditionFailed('version_conflict', 'Version conflict on delete.');
}
}// tests/integration/reviews-conditional.test.js
describe('conditional DELETE /v1/reviews/:id', () => {
it('204 when the version matches', async () => {
const r = await request(app)
.delete('/v1/reviews/rev_101')
.set('Authorization', `Bearer ${employeeToken}`)
.set('If-Match', '"v3"');
assert.equal(r.status, 204);
assert.equal(r.text, '');
});
it('412 version_conflict when the version does not match', async () => {
const r = await request(app)
.delete('/v1/reviews/rev_102')
.set('Authorization', `Bearer ${employeeToken}`)
.set('If-Match', '"v1"'); // the current one is v3
assert.equal(r.status, 412);
assert.equal(r.body.error.code, 'version_conflict');
assert.deepEqual(r.body.error.details, []);
});
it('428 precondition_required when If-Match is missing', async () => {
const r = await request(app)
.delete('/v1/reviews/rev_102')
.set('Authorization', `Bearer ${employeeToken}`);
assert.equal(r.status, 428);
assert.equal(r.body.error.code, 'precondition_required');
});
it('404 when it does not exist, even with a correct If-Match', async () => {
const r = await request(app)
.delete('/v1/reviews/rev_999')
.set('Authorization', `Bearer ${employeeToken}`)
.set('If-Match', '"v1"');
assert.equal(r.status, 404);
assert.equal(r.body.error.code, 'review_not_found');
});
});A design note: the 404 is checked before the If-Match, because it makes no sense to talk about the version of something that does not exist, and because answering 412 on a non-existent resource would leak information about its existence.
Solution 3
The CPU sitting at 25% rules out the Node process itself being the bottleneck. The distance between p50 and p99 (40×) points to something that happens only sometimes, not to a constant cost.
| No. | Likely cause | How to verify it | Fix |
|---|---|---|---|
| 1 | N+1 when expanding items.coffee: one query per item |
Count the queries in one request; EXPLAIN QUERY PLAN; the slow query log |
A grouped query with IN (?,?,?) and grouping in memory (section 14) |
| 2 | A missing index on orders(customer_id, created_at): with few orders the scan is fast and with many it is not |
EXPLAIN QUERY PLAN shows SCAN instead of SEARCH |
Create the composite index |
| 3 | Write contention in SQLite: reads wait for an INSERT to finish |
Correlate the latency spikes with concurrent writes | WAL mode, shorter transactions, or migrating to a client-server engine |
| 4 | Uneven data distribution: one customer with 500 orders against the majority with 3 | Compare latency by customerId; measure with realistic data |
Cursor pagination, an expand limit, caching the heavy case |
| 5 | Serialising large responses: expand multiplies the size |
Compare the response size against the latency | fields to slim it down; compression |
| 6 | Garbage collector pauses from large responses held in memory | Process metrics; --trace-gc |
Reduce the response size; stream if they are enormous |
The procedure: first the slow query log from section 14, which in five minutes tells 1-2-3 (the database) apart from 5-6 (the process). Then measure against the budget (a p99 of 400 ms for writes; for this read it should be in the region of 150 ms) and stop when it is met, neither before nor after.
Conclusion
The "cacheable" constraint from 01-04 is now implementation. You know the four levels of caching and the rule that avoids the most serious leak — private on anything that depends on who is asking; you have mastered Cache-Control directive by directive, including that no-cache which does not mean "do not cache" and that stale-while-revalidate which eliminates the refresh wait; you have Aroma Store's policy resource by resource, with the catalogue cached and /v1/orders always revalidated. You have implemented conditional validation with a strong ETag — from a hash of the body or from the version field that already existed — and the complete 304 flow. And above all, the circle left open in 03-05 has been closed: If-Match and the 412 solve the lost update with standard HTTP semantics, they work with DELETE, intermediaries understand them and they reuse the very ETag the client received when reading; in exchange, the error catalogue grows with precondition_required (428), which has to be documented in openapi.yaml. In the project you have src/middleware/cache.js with cacheFor, conditionalEtag and requireIfMatch, src/services/cache.js with cache-aside and locking, src/services/cache-invalidation.js, and compression at position 7 with conditionalEtag at position 10 of src/app.js. Beyond caching: compression, keep-alive, indexes, the N+1 solved with a grouped query, fields, cursor pagination and the 202 with a task resource for the invoice. And, first of all even though it is told last, the latency budget, percentiles rather than the mean, and autocannon for measuring before touching anything.
Only one thing remains before closing the module, and it is what makes everything else possible: knowing what is going on. In 04-07, Observability: Logs, Metrics and Traces, we will finally replace the console.log at position 5 of src/app.js with structured logs using pino, with a child logger per request carrying the traceId we have been emitting since 03-02, the fields that must always be logged and the ones that must never be — passwords, tokens, personal data — with their automatic redaction. We will instrument Aroma Store with prom-client and the four golden signals, exposing a protected /metrics outside /v1, with the warning about the cardinality that blows up metrics systems. We will look at distributed tracing with traceparent and OpenTelemetry over a complete POST /v1/orders — validation, SQL, an inventory gRPC call and a webhook — the difference between liveness and readiness in /health, and how you alert on symptoms and SLOs rather than on CPU. And we will close the module with a tally of everything that has been hardened.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
