Build one small API from route to response, then see when a database and cache join the request.
Tutorials in Web Development come first when available.
Your browser has already done the work of finding a website and opening a secure connection. Now a request reaches your application:
GET /products/42 HTTP/1.1
Host: localhost:3000
What happens next is the server-side half of a page visit. Something must read that path, decide which code owns it, find the requested data, turn the result into a response, and handle the cases where one of those steps fails.
This tutorial builds that journey in a tiny program called Request Lab. It is not a production framework and it is not trying to be one. Its job is to make the path visible:
request → route → application code → data lookup → response
↘ cache can answer first
By the end, you will be able to read a slow or failing endpoint and ask a useful question: did the request fail to reach the server, fail to match a route, fail while fetching data, or return an answer the client cannot use?
Create a file called server.mjs. This is the smallest useful HTTP server in Node.js:
import http from "node:http";
const server = http.createServer((request, response) => {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Request Lab is running");
});
server.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
Run it with:
node server.mjs
Then visit http://localhost:3000 or run curl http://localhost:3000 in another terminal. The browser or curl is the client. This Node program is the server. The request is a structured message from the client, and the response is the structured answer.
At this point, every path gets the same message. Ask for /products/42, /account, or /nothing-here; the server does not care yet. It only knows that a request arrived.
That is a useful boundary. The web journey before this point belongs to the browser, DNS, and the connection. The journey after this point belongs to your application.
Most servers do not answer every path with the same code. A router matches a request to a handler. Frameworks such as Express, Fastify, and Next.js give this a polished API, but the decision is simple enough to write by hand first. From here on, each fragment changes the same server.mjs file. The complete copyable version appears near the end.
Replace the server callback with this version:
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, "http://localhost:3000");
if (request.method === "GET" && url.pathname === "/health") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ status: "ok" }));
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Route not found" }));
});
Try both paths:
curl -i http://localhost:3000/health
curl -i http://localhost:3000/products/42
The -i flag shows headers as well as the body. You should see 200 for the health check and 404 for the missing route.
GET /health → this server has a matching handler → 200
GET /products/42 → no handler exists yet → 404
A 404 from this program means the request successfully reached the application. That is very different from a browser saying it cannot find the site. The same number, 404, tells you which layer was able to answer and which layer could not find the requested resource.
The fixed path /products/42 is not really one route. It is a route pattern: /products/:id. The last part identifies the product the client wants.
Add a tiny matcher just for this one pattern:
function productIdFrom(pathname) {
const match = pathname.match(/^\/products\/(\d+)$/);
return match ? Number(match[1]) : null;
}
Then add this inside that same server callback, after the line that creates url:
const productId = productIdFrom(url.pathname);
if (request.method === "GET" && productId !== null) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ requestedProductId: productId }));
return;
}
Now /products/42 and /products/7 reach the same handler with different input. The route does not contain the product data. It only turns a piece of the request into a value your application can use.
This is why a route bug and a data bug are different. If /products/forty-two returns 404, the route pattern rejected the input. If /products/42 matches but returns no product, the route worked and the data lookup is the next place to inspect.
For the first version, use an in-memory map. It behaves like a small database table without asking you to install anything:
const products = new Map([
[42, { id: 42, name: "Notebook", price: 12 }],
[7, { id: 7, name: "Coffee mug", price: 18 }],
]);
async function findProduct(id) {
return products.get(id) ?? null;
}
The async keyword is deliberate. A Map returns immediately, but a real data source might need a network round trip, a connection from a pool, and a query. Giving the lookup an asynchronous shape means the handler does not need to change its overall flow when the implementation changes.
if (request.method === "GET" && productId !== null) {
const product = await findProduct(productId);
if (!product) {
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Product not found" }));
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(product));
return;
}
Test both outcomes:
curl -i http://localhost:3000/products/42
curl -i http://localhost:3000/products/999
The first request returns a product. The second reaches the same route and makes the same lookup, but the source of truth has no row for 999. Returning 404 here is useful because the client asked for a resource that does not exist, not because your server crashed.
In a real application, findProduct might use PostgreSQL:
async function findProduct(id) {
const result = await database.query(
"SELECT id, name, price FROM products WHERE id = $1",
[id],
);
return result.rows[0] ?? null;
}
The database is not a separate step that happens because websites need databases. It answers one precise application question: "What product has this ID?" That is why a slow database can make a route slow even when the route code itself is small.
The body is only part of a response. The status and headers tell the client what the body means.
HTTP/1.1 200 OK
Content-Type: application/json
{"id":42,"name":"Notebook","price":12}
Content-Type: application/json tells a browser or frontend client how to interpret the bytes. 200 means the request succeeded. A JSON body with a 200 status is not automatically a good API contract, though. Your client needs stable fields and predictable error shapes.
For example, these two responses are easier to use than a mix of plain text and random objects:
{ "id": 42, "name": "Notebook", "price": 12 }
{ "error": "Product not found" }
When you build a frontend later, it can handle the two cases on purpose. It does not need to guess whether the string Oops means a missing product, a database failure, or a typo in the route.
Imagine the product page for item 42 is popular. If every request asks the database the same question, the application repeats work. A cache keeps a reusable answer close to the application.
Add this small cache:
const productCache = new Map();
const CACHE_TTL_MS = 30_000;
async function getProduct(id) {
const cached = productCache.get(id);
if (cached && cached.expiresAt > Date.now()) {
return { product: cached.value, source: "cache" };
}
const product = await findProduct(id);
if (product) {
productCache.set(id, {
value: product,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
return { product, source: "database" };
}
Replace findProduct(productId) in the route with getProduct(productId). You can log the source temporarily while learning:
const { product, source } = await getProduct(productId);
console.log(`product ${productId} came from ${source}`);
The route's job has not changed. It still needs a product. The lookup has changed its strategy:
first request → cache miss → database → save answer → response
later request → cache hit → response
This is the point of caching. It avoids repeat work. It does not make data correct, and it does not eliminate the database. If a product's price changes, the cached answer can be old until its time to live expires or the application removes it deliberately.
Suppose someone changes the price of product 42 from 12 to 15. If you update the database and leave the cache untouched, a reader may still see 12 for up to 30 seconds.
write: update product 42 to price 15
↓
delete cache entry for product 42
↓
next read: database returns 15, cache stores 15
That second line is cache invalidation. It is not an optional cleanup job. It is the decision that keeps a reusable answer close enough to the source of truth.
Different data can tolerate different ages. A versioned image file can be cached for a year. A public product name may be fine for a minute. An account balance usually needs a much tighter rule. The useful engineering question is not "should we cache?" It is "how stale may this answer be for this user?"
Our Map cache belongs to one Node process. If you run four application servers, each gets its own copy. A shared cache such as Redis makes cached answers available to all four, but it introduces another network dependency that can fail. That is a trade-off, not an automatic upgrade.
Caching protects the database only after an answer is stored. Imagine 100 people request product 42 at the same moment after its cache entry expires. All 100 requests can see the same cache miss before the first database query finishes.
100 requests → 100 cache misses → 100 database queries
This is often called a cache stampede. The cache exists, but the busy moment is precisely when it does not help yet. One small-process response is to keep track of a lookup already in progress so later requests can wait for the same promise instead of starting their own query.
const inFlight = new Map();
async function getProductOnce(id) {
if (!inFlight.has(id)) {
inFlight.set(id, getProduct(id).finally(() => inFlight.delete(id)));
}
return inFlight.get(id);
}
This code is not a complete distributed solution. It only coordinates requests inside one Node process. In this Request Lab, replace the route's getProduct(productId) call with getProductOnce(productId) so the route actually uses it. It does show the actual question production systems must answer: when many people ask the same expensive question, which work can they safely share?
What should your route do if the database is unavailable? It should not return 404, because the product may exist. It should tell the client that the server could not complete a valid request.
Wrap the data call with a boundary:
try {
const { product } = await getProductOnce(productId);
if (!product) {
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Product not found" }));
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(product));
} catch (error) {
console.error("Could not load product", { productId, error });
response.writeHead(503, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Please try again shortly" }));
}
503 Service Unavailable says the server is temporarily unable to do the work. That gives a client a chance to show a retry message, while your logs keep the technical reason for the team operating the service.
Do not send raw database errors to the browser. They can expose implementation details and they do not help a reader recover. Give the client a stable, safe message. Keep the diagnostic detail in server logs with the request path, product ID, and failure.
The earlier snippets make one idea visible at a time. If you want a single file you can run, replace server.mjs with this complete version. It uses the in-memory Map on purpose, so it has no packages or database setup. The route still behaves like one backed by a database and cache.
import http from "node:http";
const products = new Map([
[42, { id: 42, name: "Notebook", price: 12 }],
[7, { id: 7, name: "Coffee mug", price: 18 }],
]);
const productCache = new Map();
const inFlight = new Map();
const CACHE_TTL_MS = 30_000;
function sendJson(response, status, body) {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
function productIdFrom(pathname) {
const match = pathname.match(/^\/products\/(\d+)$/);
return match ? Number(match[]) : ;
}
() {
products.(id) ?? ;
}
() {
cached = productCache.(id);
(cached && cached. > .()) {
{ : cached., : };
}
product = (id);
(product) {
productCache.(id, {
: product,
: .() + ,
});
}
{ product, : };
}
() {
(!inFlight.(id)) {
inFlight.(id, (id).( inFlight.(id)));
}
inFlight.(id);
}
server = http.( (request, response) => {
url = (request. ?? , );
(request. === && url. === ) {
(response, , { : });
;
}
productId = (url.);
(request. !== || productId === ) {
(response, , { : });
;
}
{
{ product, source } = (productId);
.();
(!product) {
(response, , { : });
;
}
(response, , product);
} (error) {
.(, { productId, error });
(response, , { : });
}
});
server.(, {
.();
});
Run the server, then make these requests in another terminal:
node server.mjs
curl -i http://localhost:3000/health
curl -i http://localhost:3000/products/42
curl -i http://localhost:3000/products/999
The first product request logs database. A second request before the 30-second expiry logs cache. That is an observation you can make, not a claim you have to trust.
When this endpoint is slow or wrong, follow the exact request rather than guessing.
GET /products/42
↓
Did the server log the request?
↓
Did the route match the product ID?
↓
Was it a cache hit or cache miss?
↓
Did the data lookup return a product, no product, or an error?
↓
What status and JSON body left the server?
Here is the same route seen as a diagnosis table:
| What you observe | What it proves | Next place to inspect |
|---|---|---|
| No server log | The request may not have reached this application. | URL, proxy, deployment, load balancer. |
404 Route not found | The server ran, but no route matched. | Method and path pattern. |
404 Product not found | The route and lookup ran. | Product ID and data source. |
503 Please try again shortly | The route handled a dependency failure. | Database, cache, upstream service, logs. |
200 with old data | An answer was found, but it may be stale. | Cache TTL and invalidation after writes. |
This is the habit that scales beyond this tiny server. One endpoint is not "slow" as a whole. It has stages. Locate the last stage that definitely worked, then inspect the next stage.
When a URL reaches your server, the browser's part is not the whole story. Your application still needs to match a route, turn input into values, ask a source of truth for data, decide whether a cache can answer, and describe success or failure precisely in HTTP.
Request Lab is small, but the ideas do not disappear in a framework. Express route declarations, Next.js route handlers, database clients, Redis, and load balancers all make this path easier to operate at a larger scale. They do not replace the path.
The next time you add an endpoint, write the journey in one line first: "A GET /products/:id request matches this handler, looks here for data, may use this cached answer, and returns these success and failure responses." If you can explain that sentence, you have designed the useful part of the endpoint before the code becomes complicated.