Build a Speed Test API: Caching Layers
Add caching layer by layer and watch response times drop from 500ms to 1ms.
Introduction
30 Second Summary
You know that feeling when a website loads instantly the second time you visit it? That speed comes from caching, where your browser, server, or an external store keeps a copy of data so it does not have to be fetched from scratch every time.
In this project, you will build a Speed Test API that starts painfully slow and gets dramatically faster as you add caching at each layer. You will measure the difference at every step and record your predictions before seeing the actual results.
What You'll Build
You will hit your API, watch response times drop from 500ms to under 1ms as you stack caching strategies, and explain exactly which layer served each response using your browser DevTools.
By the end of this project, you'll have:
- A working Express API that simulates a slow database and responds with measurable timing data, so you can see exactly how long each request takes.
- Three caching layers (in-memory, Redis, and HTTP Cache-Control headers) stacked on top of each other, with visible performance improvements you can measure at each transition.
- A conviction ledger of predictions you recorded before adding each caching layer, building calibration data for your architectural judgment over time.
- Secret Mission: Design a write-through cache invalidation strategy that keeps all three cache layers consistent when data changes.
Are there any prerequisites?
You will need Node.js 18 or higher installed on your machine, plus Cursor or VS Code as your editor. You will also create a free Upstash account (no credit card required) during the project.
Before We Start
Before diving into code, take a moment to lock in what you're building, why it matters, and what you already believe about caching.
You'll revisit your initial predictions at the end of this project to see how your understanding has shifted.
Set Up the Project
You wrote your first prediction about caching. Now it is time to create the server that will test it.
In this step, you will scaffold a TypeScript project with Express. Express handles incoming HTTP requests. TypeScript catches type errors before they become runtime bugs. Together they give you a server that can simulate a slow database and then layer caches on top.
In this step, get ready to:
- Initialize a new Node.js project with TypeScript support.
- Install Express and Upstash Redis dependencies.
- Create a basic server that responds to requests on port 3000.
Create the project directory and install dependencies
Every project starts with a folder and a package.json file that tracks your dependencies.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a new Node.js project by running these commands:
mkdir speed-test-api
cd speed-test-api
npm init -y
What do these commands do?
- mkdir speed-test-api creates a new folder for your project.
- cd speed-test-api moves your terminal into that folder.
- npm init -y generates a package.json with default values, skipping all prompts.
- Confirm the project initialized by running:
ls
You should see package.json listed in the output.
- Install the project dependencies by running these commands:
npm install express @upstash/redis
npm install -D typescript @types/express @types/node ts-node
What is being installed?
The first command installs runtime packages your server needs. express is the web framework. @upstash/redis is the Redis client you will use in a later step.
The second command installs development tools. typescript is the compiler. ts-node lets you run TypeScript files directly without a separate build step. The @types/* packages provide type definitions so TypeScript understands Express and Node.js APIs.
You should see output listing the added packages with no error messages.
Seeing permission errors or network failures?
Permission errors (EACCES) mean npm cannot write to the folder. Try running the install command with sudo on macOS/Linux, or open your terminal as Administrator on Windows.
Network errors mean npm cannot reach the registry. Check your internet connection and try again.
help me fix npm install errors
Create the TypeScript configuration and server file
TypeScript needs a configuration file to know how to compile your code. Your server needs an entry point file to start handling requests.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the speed-test-api folder, then click Open.
- Create a new file called tsconfig.json in the root of your speed-test-api folder and paste in this configuration:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
What does this configuration do?
- target: "ES2020" tells TypeScript to compile to modern JavaScript features.
- strict: true enables all strict type-checking options so TypeScript catches more bugs.
- rootDir: "./src" tells TypeScript your source code lives in the src folder.
- esModuleInterop: true lets you use import express from "express" syntax cleanly.
- Create a folder called src inside your project by right-clicking in the Cursor file explorer and selecting New Folder.
- Inside src, create a new file called index.ts and paste in this code:
import express from "express";
// Create Express app and enable JSON body parsing
const app = express();
app.use(express.json());
const PORT = 3000;
// Root endpoint to confirm the server is running
app.get("/", (req, res) => {
res.json({ message: "Speed Test API is running" });
});
// Start the server and log the URL
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- express() creates your web server application.
- app.use(express.json()) enables parsing JSON request bodies, which you will need later for POST requests.
- app.get("/", ...) defines a route that responds to GET requests at the root URL with a confirmation message.
- app.listen(PORT, ...) starts the server on port 3000 and prints the URL so you know it is ready.
- Save both files (tsconfig.json and src/index.ts).
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
Here is the complete src/index.ts:
import express from "express";
const app = express();
app.use(express.json());
const PORT = 3000;
app.get("/", (req, res) => {
res.json({ message: "Speed Test API is running" });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Start the server and confirm it responds
Your project is assembled. Time to start it up and confirm everything connects.
- Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
Before you run this, what do you expect to see printed in the terminal? Think about what app.listen does in your code.
- Start your server by running this command:
npx ts-node src/index.ts
You should see Server running on http://localhost:3000 printed in your terminal.
Why does the terminal seem frozen?
The server is running and waiting for requests. It takes over your terminal until you stop it. To stop the server later, press Ctrl+C. For now, leave it running.
- Open your browser and navigate to http://localhost:3000.
You should see a JSON response: {"message":"Speed Test API is running"}.
Not seeing the response in your browser?
- Check your terminal for error messages. A common issue is a missing module, which means ts-node or another dependency did not install. Run npm install again and retry.
- If you see EADDRINUSE, port 3000 is already in use by another process. Stop that process or change the PORT value in src/index.ts.
- Make sure you saved both tsconfig.json and src/index.ts before running the command.
help me fix my Express server not starting
Your server is alive. Next up, you will create a deliberately slow endpoint that simulates a database query taking 500ms per request. That slow endpoint is the problem you will spend the rest of this project solving.
Build the Slow API (No Cache)
Your server responds instantly right now, but real APIs call databases. Those queries take time because of network latency, disk reads, and query processing.
In this step, you'll create a fake database that deliberately takes 500ms to respond. Every single request hits that delay with no shortcut. This is the problem you'll solve layer by layer in the rest of the project.
In this step, get ready to:
- Create a simulated database module that takes 500ms per query.
- Add a /api/products route that measures and reports response time.
- Record a prediction about what happens when you cache data in a variable.
Create the simulated database
Real databases are slow. Network latency, disk reads, and query processing all add up. To study caching in isolation, you need a function that behaves like a slow database without the complexity of running one.
- Create a new file called src/database.ts in Cursor by right-clicking the src folder in the Explorer sidebar and selecting New File.
- Add the product interface, sample data, and database functions by pasting this code:
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: "Mechanical Keyboard", price: 129 },
{ id: 2, name: "Wireless Mouse", price: 59 },
{ id: 3, name: "USB-C Hub", price: 45 },
{ id: 4, name: "Monitor Stand", price: 89 },
{ id: 5, name: "Webcam HD", price: 79 },
];
export async function getProducts(): Promise<Product[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([...products]);
}, 500);
});
}
export function addProduct(product: Omit<Product, "id">): Product {
const newProduct: Product = {
id: products.length + 1,
...product,
};
products.push(newProduct);
return newProduct;
}
What does this code do?
- The Product interface defines the shape of every product record: an id, a name, and a price.
- The products array holds five hardcoded items that act as your database table.
- The getProducts() function wraps a setTimeout in a Promise. It waits 500ms before returning the data, forcing every caller to wait half a second.
- The addProduct() function lets callers add new items to the array. You will use this in a later step to test what happens when cached data goes stale.
- Save database.ts with Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the file was created by running this in your terminal:
ls src/
You should see both database.ts and index.ts listed.
Don't see database.ts?
- Make sure you saved the file after pasting the code.
- Check that the file is inside the src/ folder, not in the project root. The path should be src/database.ts.
- help me find where my database.ts file ended up
Add the products route
Now you need an endpoint that callers can hit. This route calls your slow database, measures how long it took, and includes that timing in the response so you can see the cost of every request.
- In src/index.ts, find this line at the top:
import express from "express";
- Replace it with these two imports so the file also pulls in getProducts:
import express from "express";
import { getProducts } from "./database";
- Next, find the closing }); of the GET / route. Add the new products route below it by pasting this code:
app.get("/api/products", async (req, res) => {
// Measure how long the database call takes
const start = Date.now();
const data = await getProducts();
const responseTime = Date.now() - start;
res.json({ data, responseTime: `${responseTime}ms`, source: "database" });
});
What does this route do?
- Date.now() captures the current timestamp in milliseconds before and after the database call.
- The difference gives you the exact elapsed time for the query.
- The source field is set to "database" so you always know where the data came from. In later steps, this field changes to show which cache layer served the request.
- Save src/index.ts.
- Start the server by running:
npx ts-node src/index.ts
You should see Server running on http://localhost:3000 printed in your terminal.
Before you test, think about this: do you expect the response time to be exactly 500ms, slightly more, or less? Why?
- Open a second terminal tab and test the endpoint by running:
curl -w "\nTotal time: %{time_total}s\n" http://localhost:3000/api/products
You'll see a JSON response with five products, a responseTime of approximately 500ms or slightly above, and source: "database". The total time reported by curl includes network overhead, so it reads just over 0.5 seconds.
- Run the same curl command two more times.
Every request takes the same ~500ms. There is no shortcut. No matter how many times you ask, the server re-runs the full 500ms query every time.
Seeing 'Cannot find module ./database'?
- Make sure database.ts is saved inside the src/ folder, not in the project root.
- Check that the import path in index.ts says "./database" with no file extension.
- help me fix my database module import error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is src/database.ts in full:
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: "Mechanical Keyboard", price: 129 },
{ id: 2, name: "Wireless Mouse", price: 59 },
{ id: 3, name: "USB-C Hub", price: 45 },
{ id: 4, name: "Monitor Stand", price: 89 },
{ id: 5, name: "Webcam HD", price: 79 },
];
export async function getProducts(): Promise<Product[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([...products]);
}, 500);
});
}
export function addProduct(product: Omit<Product, "id">): Product {
const newProduct: Product = {
id: products.length + 1,
...product,
};
products.push(newProduct);
return newProduct;
}
And here is src/index.ts in full:
import express from "express";
import { getProducts } from "./database";
const app = express();
app.use(express.json());
const PORT = 3000;
app.get("/", (req, res) => {
res.json({ message: "Speed Test API is running" });
});
app.get("/api/products", async (req, res) => {
const start = Date.now();
const data = await getProducts();
const responseTime = Date.now() - start;
res.json({ data, responseTime: `${responseTime}ms`, source: "database" });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Record your prediction
You have felt the problem. Every request waits 500ms no matter what. The obvious fix is to store the result in a JavaScript variable after the first request so subsequent requests skip the database entirely.
Before you move on, write down your answers to these three questions. This is your conviction ledger. You will compare your predictions to reality in the next step.
- If you store the result in a JavaScript variable after the first request, what happens on the second request?
- What happens if you restart the server?
- What happens if you have two server instances running?
Why write predictions down?
Predictions force you to commit to a mental model before you see the answer. When your prediction is wrong, you learn faster because the surprise is concrete. Over time, you build calibration data for architectural decisions.
- Stop the server with Ctrl+C in the terminal where it is running.
You now have measurable proof that your API is slow. Next up, you'll add in-memory caching and watch those 500ms responses drop to under 1ms instantly.
Add In-Memory Cache
You felt the problem in the last step. Every single request to /api/products takes 500ms, no matter how many times you hit it.
The simplest fix is to store the result in a JavaScript variable after the first request. On subsequent requests, your server returns that stored copy instantly instead of waiting 500ms every time. This is in-memory caching. In this step, you will add it, see it work, and then watch it break.
In this step, get ready to:
- Create a cache module with TTL-based expiry logic.
- Wire the cache into your products route so repeated requests skip the database.
- Discover what happens when you restart the server.
Create the in-memory cache module
Your cache needs three capabilities: store a value with a time limit, retrieve it if it has not expired, and clear it when needed.
- Create a new file src/cache.ts and add the following code:
interface CacheEntry {
data: any;
timestamp: number;
ttlMs: number;
}
// In-memory store for cached data
const store = new Map<string, CacheEntry>();
export function getFromCache(key: string): any | null {
const entry = store.get(key);
if (!entry) return null;
// Return null if entry has expired
const age = Date.now() - entry.timestamp;
if (age > entry.ttlMs) {
store.delete(key);
return null;
}
return entry.data;
}
export function setCache(key: string, data: any, ttlMs: number): void {
store.set(key, { data, timestamp: Date.now(), ttlMs });
}
export function clearCache(key: string): void {
store.delete(key);
}
What does this code do?
The store is a plain JavaScript Map that lives in your server's process memory. It holds cached data alongside the timestamp of when it was stored.
When getFromCache is called, it checks two things: does the key exist, and is the entry still within its TTL (Time-To-Live)? If the entry is older than the TTL, it gets deleted and the function returns null as if nothing was cached.
- Save src/cache.ts.
- Confirm the file exists by running:
ls src/
You should see cache.ts, database.ts, and index.ts listed.
Don't see cache.ts?
Make sure you created the file inside the src/ folder, not in the project root. The file path should be src/cache.ts.
help me find where my cache.ts file ended up
Wire the cache into your products route
Now you need to teach your route to check the cache before calling the slow database. If the cache has fresh data, return it immediately. If not, fetch from the database and store the result for next time.
- In src/index.ts, find this import line at the top:
import { getProducts } from "./database";
- Add this new import line directly below it:
import { getFromCache, setCache } from "./cache";
- Find this line:
const PORT = 3000;
- Add these two constants directly below it:
const CACHE_KEY = "products";
const MEMORY_TTL_MS = 60000;
What are these constants?
CACHE_KEY is the name your cache uses to store and retrieve the products data. MEMORY_TTL_MS is 60000 milliseconds (60 seconds). After 60 seconds, the cached data expires and the next request fetches fresh data from the database.
- Replace the entire app.get("/api/products", ...) route handler with this updated version:
app.get("/api/products", async (req, res) => {
const start = Date.now();
// Check in-memory cache first
const memoryCached = getFromCache(CACHE_KEY);
if (memoryCached) {
const responseTime = Date.now() - start;
res.json({ data: memoryCached, responseTime: `${responseTime}ms`, source: "memory-cache" });
return;
}
// Cache miss - fetch from slow database and store result
const data = await getProducts();
setCache(CACHE_KEY, data, MEMORY_TTL_MS);
const responseTime = Date.now() - start;
res.json({ data, responseTime: `${responseTime}ms`, source: "database" });
});
What does this route do now?
- First, it checks the in-memory cache. If the data is there and not expired, it returns immediately with source: "memory-cache".
- If the cache is empty or expired, it falls through to the slow getProducts() call, waits 500ms, then stores the result in the cache before responding.
- The source field in the response tells you exactly where the data came from. This is how you will measure the improvement.
- Save src/index.ts.
- Stop the running server with Ctrl+C if it is still running, then start it again by running:
npx ts-node src/index.ts
- Test the first request by running this in a separate terminal:
curl -w "\nTotal time: %{time_total}s\n" http://localhost:3000/api/products
You should see "source": "database" with a total time around 0.5 seconds. This is the first request populating the cache.
- Run the exact same command again:
curl -w "\nTotal time: %{time_total}s\n" http://localhost:3000/api/products
This time you should see "source": "memory-cache" with a total time near 0.00 seconds. The database was never touched.
Still seeing source: database on the second request?
Make sure you saved src/index.ts and restarted the server after your edits. If you edited the file while the server was running, you need to stop it with Ctrl+C and start it again.
Also check that your import line says import { getFromCache, setCache } from "./cache" with both function names included.
help me debug why my in-memory cache is not working
Watch the cache disappear
Your in-memory cache works. Requests dropped from 500ms to nearly 0ms. But here is the question you predicted earlier: what happens when the server restarts?
- Stop the server by pressing Ctrl+C in the terminal where it is running.
- Start it again by running:
npx ts-node src/index.ts
Before you hit the endpoint, do you think the response will come from the cache or the database? Why?
- Test your prediction by running:
curl -w "\nTotal time: %{time_total}s\n" http://localhost:3000/api/products
The response shows "source": "database" and the time is back to ~0.5 seconds. The cache is gone.
Why did the cache vanish?
The Map in src/cache.ts lives inside your Node.js process memory. When you stop the server, that process is destroyed along with everything stored in it. When you restart, a brand-new empty Map is created.
This also means if you had two servers running behind a load balancer, each would have its own separate cache. A request hitting server A would never benefit from server B's cache.
This is the limitation you predicted. Write in your conviction ledger: "What kind of cache would survive a server restart? What trade-off am I accepting by moving the cache outside my process?"
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/cache.ts and src/index.ts.
ⓧ I'd like to double check the full code
Here is src/cache.ts in full:
interface CacheEntry {
data: any;
timestamp: number;
ttlMs: number;
}
const store = new Map<string, CacheEntry>();
export function getFromCache(key: string): any | null {
const entry = store.get(key);
if (!entry) return null;
const age = Date.now() - entry.timestamp;
if (age > entry.ttlMs) {
store.delete(key);
return null;
}
return entry.data;
}
export function setCache(key: string, data: any, ttlMs: number): void {
store.set(key, { data, timestamp: Date.now(), ttlMs });
}
export function clearCache(key: string): void {
store.delete(key);
}
For src/index.ts, confirm these pieces are present in order:
- Three imports at the top: express, getProducts from ./database, and getFromCache plus setCache from ./cache.
- Constants: PORT = 3000, CACHE_KEY = "products", MEMORY_TTL_MS = 60000.
- The GET / route returning the running message.
- The GET /api/products route checking getFromCache first, then falling through to getProducts() on a miss.
- The app.listen call at the bottom.
You just proved that in-memory caching is incredibly fast but fatally fragile. Next up, you will add a cache that survives restarts by moving it outside your process entirely.
Add Redis Cache
In the previous step, you watched the in-memory cache vanish the instant you restarted the server. Process memory is tied to the process lifecycle. When the process dies, every cached value disappears with it.
Redis is an external key-value store that lives outside your server process. It survives restarts and can serve multiple server instances simultaneously. In this step, you'll connect to Upstash Redis and add it as a second cache layer between your in-memory cache and the slow database.
In this step, get ready to:
- Connect your project to Upstash Redis with credentials and a helper module.
- Upgrade the /api/products route to a three-tier cache lookup (memory, Redis, database).
- Prove the cache survives a server restart.
Create the Redis helper and connect to Upstash
Your project needs two things to talk to Upstash Redis: credentials (a URL and a token) and a helper module that wraps the Redis client into reusable functions.
- Switch to your Upstash console at console.upstash.com.
- Click on your Redis database to open its details page.
- Open the REST API section (or the Connect section, then the REST tab).
- Copy the value next to UPSTASH_REDIS_REST_URL.
- Copy the value next to UPSTASH_REDIS_REST_TOKEN.
- Create a .env file in the root of your speed-test-api folder and paste your credentials into it:
UPSTASH_REDIS_REST_URL=your-upstash-url-here
UPSTASH_REDIS_REST_TOKEN=your-upstash-token-here
- Replace your-upstash-url-here and your-upstash-token-here with the actual values you copied from the Upstash console.
Why a .env file?
The @upstash/redis library's Redis.fromEnv() method reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from your environment variables automatically. The .env file keeps these credentials out of your code and out of version control.
- Install the dotenv package so your .env file is loaded at runtime:
npm install dotenv
Now create the Redis helper module that wraps the Upstash client.
- Create a new file src/redis.ts and add the following code:
import { Redis } from "@upstash/redis";
// Create Redis client using environment variables
const redis = Redis.fromEnv();
export async function redisGet<T>(key: string): Promise<T | null> {
const data = await redis.get<T>(key);
return data;
}
export async function redisSet(
key: string,
value: any,
ttlSeconds: number
): Promise<void> {
await redis.set(key, JSON.stringify(value), { ex: ttlSeconds });
}
export async function redisDel(key: string): Promise<void> {
await redis.del(key);
}
What does this code do?
- Redis.fromEnv() creates an HTTP-based Redis client by reading your UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN environment variables.
- redisGet fetches a value by key, returning null if the key doesn't exist or has expired.
- redisSet stores a value with an explicit TTL in seconds using the { ex: ttlSeconds } option. Redis automatically deletes the key after the TTL expires.
- redisDel manually removes a key from Redis. You'll use this later for cache invalidation.
- Save src/redis.ts and .env.
- Start the server with the dotenv loader by running:
npx ts-node -r dotenv/config src/index.ts
- Confirm you see Server running on http://localhost:3000 with no errors about missing environment variables.
Seeing an error about missing credentials?
- Make sure your .env file is in the root of speed-test-api (the same folder as package.json), not inside src/.
- Double-check that you copied the full URL and token from the Upstash console with no extra spaces.
- Verify you included the -r dotenv/config flag in the run command. Without it, the .env file is not loaded.
Why isn't my Redis connection working?
Upgrade the route to three-tier cache lookup
Your route currently checks in-memory cache, then falls through to the database. You'll now insert Redis as the middle tier so the lookup order becomes: memory (fastest), then Redis (survives restarts), then database (slowest).
- In src/index.ts, find this import line at the top:
import { getFromCache, setCache } from "./cache";
- Add the Redis import directly below it so you have:
import { getFromCache, setCache } from "./cache";
import { redisGet, redisSet } from "./redis";
- Find the line that says const MEMORY_TTL_MS = 60000; and add the Redis TTL constant directly below it:
const MEMORY_TTL_MS = 60000;
const REDIS_TTL_SECONDS = 60;
Now replace the entire route handler with the three-tier lookup logic.
- Find the existing app.get("/api/products", ...) route and replace it with this code:
app.get("/api/products", async (req, res) => {
const start = Date.now();
// Tier 1: Check in-memory cache (fastest, dies on restart)
const memoryCached = getFromCache(CACHE_KEY);
if (memoryCached) {
const responseTime = Date.now() - start;
res.json({ data: memoryCached, responseTime: `${responseTime}ms`, source: "memory-cache" });
return;
}
// Tier 2: Check Redis (survives restarts, shared across instances)
const redisCached = await redisGet<string>(CACHE_KEY);
if (redisCached) {
const data = typeof redisCached === "string" ? JSON.parse(redisCached) : redisCached;
setCache(CACHE_KEY, data, MEMORY_TTL_MS);
const responseTime = Date.now() - start;
res.json({ data, responseTime: `${responseTime}ms`, source: "redis-cache" });
return;
}
// Tier 3: Hit the slow database and populate both caches
const data = await getProducts();
setCache(CACHE_KEY, data, MEMORY_TTL_MS);
await redisSet(CACHE_KEY, data, REDIS_TTL_SECONDS);
const responseTime = Date.now() - start;
res.json({ data, responseTime: `${responseTime}ms`, source: "database" });
});
What does this three-tier lookup do?
- Tier 1 checks in-memory cache first. If it hits, the response is near-instant (under 1ms) because no network call is made.
- Tier 2 checks Redis if memory misses. This makes an HTTP call to Upstash, so it takes ~50-100ms. When Redis hits, it also promotes the data back into the in-memory cache for subsequent requests.
- Tier 3 hits the slow database (~500ms) only if both caches miss. It then populates both Redis and memory so future requests skip the database entirely.
- Save src/index.ts.
- Stop your server with Ctrl+C if it's running, then start it again:
npx ts-node -r dotenv/config src/index.ts
- Hit the endpoint by running:
curl http://localhost:3000/api/products
- You should see a JSON response with "source": "database" and a response time around 500ms. This first request populates both caches.
Getting a connection error on the first request?
- If you see an error mentioning authorization or invalid token, go back to your Upstash console and re-copy the REST token. Paste it into .env and restart the server.
- Make sure the UPSTASH_REDIS_REST_URL value starts with https://.
Why is my Upstash request failing with an authorization error?
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/index.ts and confirmed the first request returns "source": "database".
ⓧ I'd like to double check the full code
Your src/index.ts should have these sections from top to bottom. Scroll up and confirm each block is present in order:
- Imports: express, getProducts from database, getFromCache and setCache from cache, redisGet and redisSet from redis.
- Constants: PORT = 3000, CACHE_KEY = "products", MEMORY_TTL_MS = 60000, REDIS_TTL_SECONDS = 60.
- GET / route returning the status message.
- GET /api/products route with the three-tier lookup (memory, Redis, database).
- app.listen on PORT 3000.
Your src/redis.ts should look like this:
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export async function redisGet<T>(key: string): Promise<T | null> {
const data = await redis.get<T>(key);
return data;
}
export async function redisSet(
key: string,
value: any,
ttlSeconds: number
): Promise<void> {
await redis.set(key, JSON.stringify(value), { ex: ttlSeconds });
}
export async function redisDel(key: string): Promise<void> {
await redis.del(key);
}
Prove the cache survives a server restart
This is the moment of truth. The whole reason you added Redis is so the cache persists even when the server process dies.
Before you run the test, predict what will happen: you'll stop the server, restart it, and hit the endpoint. What source do you expect to see in the response? Why?
- Stop your server with Ctrl+C.
- Start it again:
npx ts-node -r dotenv/config src/index.ts
- Hit the endpoint again:
curl http://localhost:3000/api/products
You should see "source": "redis-cache" with a response time around 50-100ms. The in-memory cache is gone (it died with the old process), but Redis still has the data.
- Hit the endpoint one more time without restarting:
curl http://localhost:3000/api/products
This time you should see "source": "memory-cache" at under 1ms. The Redis hit from the previous request promoted the data back into the in-memory cache.
What are the trade-offs?
Compare the three sources you've seen: database at ~500ms, Redis at ~50-100ms, and memory at under 1ms. Redis is 5-10x faster than the database, but still ~50-100x slower than local memory because each Redis call is an HTTP round-trip to Upstash's servers.
The three-tier pattern gives you the best of both worlds. Memory handles the hot path with near-zero latency. Redis catches restarts and cold starts. The database is the last resort.
Notice that Redis is faster than the database but slower than local memory. That is the fundamental trade-off of distributed caching: durability and shareability cost you a network hop.
Still seeing source: database after restart?
- If more than 60 seconds passed between your first request and the restart test, the Redis TTL may have expired. Hit the endpoint once to repopulate, then immediately restart and test again.
- Check that the first request before restart actually returned "source": "database" (confirming Redis was populated). If it returned an error, Redis was never populated.
Why isn't my Redis cache persisting across restarts?
Write a prediction in your conviction ledger: "The server-side cache is solved. But right now, the browser makes a network request every single time. What if the browser could skip the network entirely for data that has not changed?"
Your API now has a cache that survives server restarts. In the next step, you'll add the final layer: HTTP Cache-Control headers that let the browser skip the network request entirely.
Add HTTP Cache-Control Headers
Your server-side caching is working. In-memory cache serves requests in under 1ms, and Redis persists that cache across restarts. But there is still one inefficiency: the browser makes a full network round-trip to your server on every single request, even when the data has not changed.
HTTP Cache-Control headers fix this by instructing the browser to skip the network entirely for fresh data. This is the fastest cache possible. No request is made at all.
In this step, get ready to:
- Add Cache-Control and ETag headers to the /api/products response.
- Create a contrast endpoint /api/products/live that always fetches fresh.
- Verify the browser caches responses using DevTools.
Update your imports for caching headers
To generate an ETag header, you need Node's built-in crypto module. You also need to import clearCache and redisDel (plus addProduct) so the file is ready for cache invalidation later.
- In src/index.ts, find the import lines at the top of the file:
import express from "express";
import { getProducts } from "./database";
import { getFromCache, setCache } from "./cache";
import { redisGet, redisSet } from "./redis";
- Replace them with these updated imports:
import express from "express";
import { getProducts, addProduct } from "./database";
import { getFromCache, setCache, clearCache } from "./cache";
import { redisGet, redisSet, redisDel } from "./redis";
import crypto from "crypto";
What changed in the imports?
- addProduct, clearCache, and redisDel are added for cache invalidation (you will use them in the Secret Mission).
- crypto is Node's built-in module for generating MD5 hashes. You will use it to create ETag fingerprints of each response body.
Add headers to the memory-cache branch
The pattern is the same for every cache branch: serialize the response body as a string, hash it into an ETag, set the headers, then send. You will apply this pattern to the memory-cache branch first.
- In src/index.ts, find the if (memoryCached) block inside the GET /api/products route. It currently looks like this:
const memoryCached = getFromCache(CACHE_KEY);
if (memoryCached) {
const responseTime = Date.now() - start;
res.json({ data: memoryCached, responseTime: `${responseTime}ms`, source: "memory-cache" });
return;
}
- Replace that entire block with this code:
// Check in-memory cache first (fastest)
const memoryCached = getFromCache(CACHE_KEY);
if (memoryCached) {
const responseTime = Date.now() - start;
// Serialize body so we can hash it for ETag
const body = JSON.stringify({ data: memoryCached, responseTime: `${responseTime}ms`, source: "memory-cache" });
const etag = crypto.createHash("md5").update(body).digest("hex");
// Tell browser: cache this for 30s, serve stale for 60s while revalidating
res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
res.set("ETag", `"${etag}"`);
res.json(JSON.parse(body));
return;
}
What do these headers do?
- Cache-Control: public, max-age=30, stale-while-revalidate=60 tells the browser to cache the response for 30 seconds. For 60 seconds after that, the browser may serve stale data while fetching fresh in the background.
- ETag is a fingerprint of the response body. If the browser sends this back later, the server can compare hashes and return a 304 (not modified) instead of resending the whole response.
- The body is serialized to a string first so you can both hash it (for ETag) and parse it back into JSON for the response.
- Save src/index.ts.
- Restart your server and test the memory-cache path by running:
curl -I http://localhost:3000/api/products
- Hit it a second time (so the memory cache is warm) and look for Cache-Control and ETag in the response headers.
Not seeing Cache-Control in the headers?
- Make sure you saved src/index.ts and restarted the server with Ctrl+C then npx ts-node -r dotenv/config src/index.ts.
- The first request hits the database (cache is cold after restart). Run curl -I a second time to trigger the memory-cache branch where you added the headers.
- help me debug why Cache-Control is not appearing
Apply the same pattern to the Redis and database branches
The Redis-cache and database branches need the same header logic. The only difference is the data source and source label in the response.
- In src/index.ts, find the if (redisCached) block. Replace the section from const responseTime through return; with this:
const responseTime = Date.now() - start;
// Same pattern: serialize, hash, set headers, send
const body = JSON.stringify({ data, responseTime: `${responseTime}ms`, source: "redis-cache" });
const etag = crypto.createHash("md5").update(body).digest("hex");
res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
res.set("ETag", `"${etag}"`);
res.json(JSON.parse(body));
return;
- Now find the database fallback at the bottom of the handler (after await redisSet(...)). Replace from const responseTime through the closing res.json(...) with this:
const responseTime = Date.now() - start;
// Database response also gets headers for browser caching
const body = JSON.stringify({ data, responseTime: `${responseTime}ms`, source: "database" });
const etag = crypto.createHash("md5").update(body).digest("hex");
res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
res.set("ETag", `"${etag}"`);
res.json(JSON.parse(body));
- Save src/index.ts and restart the server.
- Run curl -I http://localhost:3000/api/products twice. Both the database response (first hit) and memory-cache response (second hit) should now include Cache-Control and ETag headers.
Seeing a TypeScript error?
- If you see an error about crypto not being found, confirm the import line reads exactly import crypto from "crypto"; at the top of the file.
- If you see a JSON parse error, check that each res.json(JSON.parse(body)) line uses JSON.parse (since body is already a stringified JSON).
- help me fix a TypeScript or runtime error in my Cache-Control header code
Create the /api/products/live endpoint
You now have one endpoint that the browser can cache. To demonstrate the contrast, you will add a second endpoint that explicitly tells the browser to never cache the response.
- In src/index.ts, add this new route below the closing }); of the GET /api/products handler, and above app.listen(...):
app.get("/api/products/live", async (req, res) => {
const start = Date.now();
// Always hit the database, bypass all caches
const data = await getProducts();
const responseTime = Date.now() - start;
// no-store: browser must never cache this response
res.set("Cache-Control", "no-store");
res.json({ data, responseTime: `${responseTime}ms`, source: "database" });
});
What does no-store mean?
The no-store directive tells the browser that the response must not be stored in any cache. Every time the browser navigates to /api/products/live, it must make a full network request. This is the opposite of max-age=30 on the other endpoint.
- Save src/index.ts and restart the server.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and restarted the server.
ⓧ I'd like to double check the full code
Your src/index.ts should now have the following chunks in order:
- Updated import block (5 lines including crypto).
- Constants (PORT, CACHE_KEY, MEMORY_TTL_MS, REDIS_TTL_SECONDS).
- GET / route (unchanged).
- GET /api/products route with Cache-Control and ETag in all three branches.
- GET /api/products/live route with Cache-Control: no-store.
- app.listen at the bottom.
Scroll up and confirm each chunk is present in that order. If anything looks off, compare against the code blocks above.
Verify caching behavior in browser DevTools
Before you test, predict what will happen. When you hit /api/products twice in the browser, will the second request show up in the Network tab at all? What about /api/products/live?
- Open your browser and navigate to http://localhost:3000/api/products.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Refresh the page. This is your first request. You should see a full 200 response with actual bytes in the Size column.
- Refresh again. The second request shows (disk cache) in the Size column with 0ms network time. The browser never contacted your server.
- Now navigate to http://localhost:3000/api/products/live and refresh multiple times. Every request shows full bytes in the Size column. The browser always makes a network request because no-store prevents caching.
What is the mental model here?
Cache-Control is an instruction from your server to the browser about whether to even ask. With max-age=30, the browser does not make a network request for 30 seconds. With no-store, it always asks.
This is the same header a CDN respects. When you deploy behind Cloudflare or CloudFront, these headers tell the CDN how long to cache your response at the edge. The concept transfers directly from browser to CDN.
Still seeing full network requests on /api/products?
- Make sure you are NOT using a hard refresh (Cmd+Shift+R or Ctrl+Shift+R). A hard refresh bypasses the browser cache. Use a normal refresh (Cmd+R or Ctrl+R or F5).
- Check that DevTools does not have Disable cache checked. This option is at the top of the Network tab and forces the browser to ignore all caches while DevTools is open.
- help me figure out why my browser is not caching the response
You now have four caching layers stacked: no cache at 500ms, in-memory at under 1ms, Redis surviving restarts at 50-100ms, and HTTP headers where no request is made at all. Next up, you will tackle the hardest problem in caching: what happens when the data changes and all three layers hold stale copies.
Secret mission
Cache Invalidation
You have three cache layers that each hold copies of the same data. What happens when the data changes? Implement a write-through invalidation strategy and discover why 'there are only two hard things in computer science.'
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free-tier Upstash Redis database, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Upstash Redis database (free tier, 500K commands/month).
- Local speed-test-api project folder with Node.js dependencies.
Keep everything running
No action needed. Choose this if you are still actively building or want to revisit the project later.
- Your Upstash Redis database stays on the free tier indefinitely at no cost. If inactive for 30+ days, Upstash archives it automatically and takes a backup. You can restore it from the Upstash Console at any time.
- Your local speed-test-api folder remains on your Desktop. The node_modules folder uses around 50-100 MB of disk space.
- Your .env file contains your Upstash credentials. Keep it safe and do not commit it to version control.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Everything else stays in place for when you return.
- Stop the Express server by pressing Ctrl+C in the terminal where it is running.
- Your Upstash Redis database has no ongoing cost on the free tier. No action needed there.
- Your local project folder stays on disk. When you return, run npx ts-node -r dotenv/config src/index.ts to start the server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete the Upstash Redis database: open the Upstash Console at console.upstash.com, select your Redis database, scroll to the bottom of the database details page, and confirm the deletion.
- Remove the local project folder. On macOS or Linux, run the following command from your Desktop:
cd ~/Desktop
rm -rf speed-test-api
On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force speed-test-api
That's a Wrap!
That's a Wrap!
Nice work. You just built a Speed Test API that demonstrates measurable performance differences across four caching layers, from 500ms all the way down to zero network time.
You've learned how to:
- Built an API with four caching layers (none, in-memory, Redis, HTTP headers) and measured the response time difference at each level.
- Experienced the designed failure of in-memory cache by restarting the server and watching the cache vanish, then fixed it with Redis as a distributed cache that survives restarts.
- Configured HTTP Cache-Control headers and observed the browser skip the network entirely in DevTools, proving that the fastest request is the one never made.
- Secret Mission: Designed a write-through invalidation strategy across three cache layers and articulated why browser cache is the hardest layer to control.
Ready to quiz yourself?