Build a URL Shortener: System Design
Build a URL shortener with two ID strategies, Redis caching, and redirect semantics.
Introduction
30 Second Summary
Every time you click a shortened link on social media or in a text message, a server somewhere looks up that tiny code and redirects you to the real destination. That lookup needs to happen in milliseconds, even when millions of people click at once.
In this project, you will build a URL shortener API from scratch. You will implement two different ID generation strategies, add a Redis caching layer, and measure the real trade-offs between each approach.
What You'll Build
A local URL shortener service with two API versions, a PostgreSQL database, and a Redis cache, all running in Docker containers.
By the end of this project, you'll have:
- A working TypeScript Express API with two ID generation strategies (hash-based and counter-based Base62) and measurable collision differences.
- A cache-aside pattern implementation using Redis that demonstrates quantifiable latency improvement over direct database access.
- Concrete reasoning about 301 vs. 302 redirect semantics and when to use each, backed by observations you made in your own code.
- Secret Mission: Implement cache stampede protection with TTL expiration to handle the concurrency problem most interview candidates miss.
Are there any prerequisites?
You should be comfortable with backend development, TypeScript, and REST APIs. You also need Docker installed and running on your machine.
Set Up the Project
A URL shortener has three infrastructure pillars: an application server to handle requests, a database to store URL mappings, and a cache to speed up redirects.
In this step, you will initialize a TypeScript project, install the required packages, and spin up PostgreSQL and Redis inside Docker containers.
In this step, get ready to:
- Initialize a TypeScript project with all dependencies installed.
- Configure Docker services for PostgreSQL and Redis.
- Create the database schema for storing URL mappings.
Verify your tools and initialize the project
Before writing any code, you need to confirm that Docker and Node.js are both available on your machine.
- Confirm Docker is running by running this command:
docker info
You should see a wall of system information about your Docker installation. If you see an error like "Cannot connect to the Docker daemon", open Docker Desktop and wait for it to finish starting.
- Confirm Node.js is installed by running:
node --version
✔️ I see a version number
Node.js is ready. Move on to the next instruction below.
ⓧ Command not found
You need to install Node.js. Visit the official site to download the installer:
- Go to https://nodejs.org and download the LTS version.
- Run the installer with all default options.
- Close and reopen your terminal, then run node --version again to confirm it works.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir url-shortener-system-design
cd url-shortener-system-design
- Confirm the folder exists by running:
ls
You should see url-shortener-system-design listed in the output.
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all defaults and generate a package.json immediately. This file tracks your project's name, version, and dependencies.
The generated package.json needs two additions: an ES module declaration and a dev script.
- Open package.json in your code editor.
- Replace the entire contents with this:
{
"name": "url-shortener-system-design",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"express": "5.2.1",
"ioredis": "5.11.1",
"pg": "8.21.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "5.8.3"
}
}
What does this configuration do?
Setting "type": "module" tells Node.js to use ES module syntax (import/export) instead of CommonJS (require).
The dev script uses tsx to run TypeScript files directly without a separate compile step. The three runtime dependencies are Express (web server), ioredis (Redis client), and pg (PostgreSQL client).
- Save package.json.
- Install all listed dependencies by running:
npm install
You should see output confirming packages were added. A node_modules folder and a package-lock.json file now exist in your project.
Seeing permission errors or install failures?
Make sure you are inside the url-shortener-system-design directory (run pwd to check). If you see EACCES errors on macOS, try prefixing with sudo.
Help me debug my npm install error
Create the TypeScript and Docker configuration
TypeScript needs a configuration file to know how to compile your code. Docker Compose needs a file that describes which services to run.
- Create a new file called tsconfig.json in the project root and paste this content:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
What does this configuration do?
The target and module settings tell TypeScript to emit modern JavaScript that works with Node.js ES modules. The strict flag enables all type-checking rules, catching potential bugs before runtime.
The include array tells TypeScript to only look at files inside the src folder.
- Save tsconfig.json.
- Create a new file called docker-compose.yml in the project root and paste this content:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: urlshortener
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7
ports:
- "6379:6379"
volumes:
pgdata:
What does this file define?
This Docker Compose file defines two services. The postgres service runs PostgreSQL 16 on port 5432 with a database called urlshortener. The redis service runs Redis 7 on port 6379.
The pgdata volume persists your PostgreSQL data between container restarts. Without it, your data would disappear every time you stop the container.
- Save docker-compose.yml.
✔️ Awesome, I've got everything!
Great. Make sure both tsconfig.json and docker-compose.yml are saved.
ⓧ I'd like to double check the full code
Here is the full tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
And here is the full docker-compose.yml:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: urlshortener
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7
ports:
- "6379:6379"
volumes:
pgdata:
And here is the full package.json:
{
"name": "url-shortener-system-design",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"express": "5.2.1",
"ioredis": "5.11.1",
"pg": "8.21.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "5.8.3"
}
}
Start Docker services and create the database schema
With the configuration in place, you can now start the database and cache containers and verify they are reachable.
- Start the Docker services in the background by running:
docker compose up -d
You should see output showing both containers being created and started.
What does the -d flag do?
The -d flag runs the containers in "detached" mode. They run in the background instead of locking up your terminal. You can still see their logs later with docker compose logs.
- Verify PostgreSQL is reachable by running:
docker compose exec postgres psql -U postgres -d urlshortener -c "SELECT 1"
You should see a small table with a single column containing the value 1. This confirms PostgreSQL is running and accepting connections.
- Verify Redis is reachable by running:
docker compose exec redis redis-cli ping
You should see PONG printed in your terminal. This confirms Redis is ready.
Containers not starting?
Run docker compose logs to see error messages. Common causes: port 5432 or 6379 is already in use by another process, or Docker Desktop is not running.
If a port is in use, stop the conflicting process or change the host port in docker-compose.yml (e.g. "5433:5432").
Help me fix my Docker Compose startup error
Now you need a database table to store URL mappings. You will create a small TypeScript script that sets up the schema.
- Create the src directory by running:
mkdir src
- Create a new file called src/init-db.ts and paste this content:
import pg from "pg";
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS urls (
id SERIAL PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
clicks INTEGER DEFAULT 0,
expires_at TIMESTAMP NULL
)
`);
console.log("Database schema created successfully.");
await pool.end();
}
initDb().catch(console.error);
What does this script do?
This script connects to your local PostgreSQL database and creates a urls table. Each row will store a short code, the original long URL, a timestamp, a click counter, and an optional expiration time.
The CREATE TABLE IF NOT EXISTS clause makes the script safe to re-run. If the table already exists, it does nothing.
- Save src/init-db.ts.
- Run the schema initialization script:
npx tsx src/init-db.ts
You should see Database schema created successfully. printed in your terminal.
Seeing a connection refused error?
The PostgreSQL container may still be starting up. Wait a few seconds and try again. You can check the container status with docker compose ps to confirm both services show a "running" state.
Help me connect to my PostgreSQL container
✔️ Awesome, I've got everything!
Great. Make sure src/init-db.ts is saved and the script ran successfully.
ⓧ I'd like to double check the full code
import pg from "pg";
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS urls (
id SERIAL PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
clicks INTEGER DEFAULT 0,
expires_at TIMESTAMP NULL
)
`);
console.log("Database schema created successfully.");
await pool.end();
}
initDb().catch(console.error);
Your infrastructure is ready. PostgreSQL is storing data, Redis is standing by for caching, and your TypeScript project is configured. Next up, you will build the first version of the URL shortening endpoint using hash-based ID generation.
Build the Write Path with Hash-Based IDs
Your database and Docker services are running. Now it's time to build the core functionality: the write path that accepts a long URL and produces a short code.
You'll start with a hash-based approach using SHA-256. Hashing is deterministic. The same input always produces the same output. This property has trade-offs that you'll observe firsthand when testing.
In this step, get ready to:
- Set up the Express server with PostgreSQL and Redis connections.
- Implement hash-based short code generation with collision handling.
- Test the API and observe the deterministic behavior of hash-based IDs.
Set up the Express server and database connections
Before you can shorten URLs, you need an HTTP server that can accept requests, a database connection to store the mappings, and a Redis client for caching later. You'll also write the utility functions that convert numbers to short Base62 strings.
- Create a new file src/index.ts in your project's src folder (the same folder where init-db.ts already lives).
- Add the imports, Express app setup, database pool, Redis client, and encoding utility functions by pasting the following code:
import express from "express";
import { createHash } from "crypto";
import pg from "pg";
import Redis from "ioredis";
const app = express();
app.use(express.json());
// Connect to PostgreSQL (same credentials as docker-compose.yml)
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
// Connect to Redis for caching (used in a later step)
const redis = new Redis({ host: "localhost", port: 6379 });
// Base62 characters: 0-9, A-Z, a-z (62 total)
const BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Convert an integer to a Base62 string
function toBase62(num: number): string {
if (num === 0) return BASE62_CHARS[0];
let result = "";
while (num > 0) {
result = BASE62_CHARS[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
// Hash a URL string and return a 7-character Base62 code
function hashToBase62(input: string): string {
const hash = createHash("sha256").update(input).digest("hex");
const num = parseInt(hash.substring(0, 12), 16);
return toBase62(num).substring(0, 7);
}
What does this code do?
This sets up the foundation for your URL shortener.
- express creates the HTTP server. app.use(express.json()) lets it parse JSON request bodies.
- pg.Pool manages a pool of PostgreSQL connections using the same credentials from your Docker Compose setup.
- toBase62 converts a number into a compact string using 62 characters. hashToBase62 hashes a URL with SHA-256, takes the first 12 hex characters as a number, then encodes that number as a 7-character Base62 string.
Why Base62?
Base62 uses digits, uppercase, and lowercase letters. A 7-character Base62 string gives you 62^7 (about 3.5 trillion) possible codes. That's enough for any URL shortener at scale, while keeping codes short and URL-safe.
Implement the hash-based shorten endpoint
The hashing function converts URLs to codes, but you still need collision handling. Two different URLs might hash to the same 7-character code. Your endpoint needs to detect this and retry with a modified input.
- Add the POST endpoint and server startup below the hashToBase62 function in src/index.ts:
// --- Approach 1: Hash-based ID generation ---
app.post("/api/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
let shortCode = hashToBase62(url);
let attempts = 0;
// Retry up to 5 times if a collision occurs
while (attempts < 5) {
try {
await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[shortCode, url]
);
console.log(
`[HASH] Created: ${shortCode} -> ${url} (attempts: ${attempts})`
);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
return;
} catch (err: any) {
if (err.code === "23505") {
// unique_violation - check if same URL already stored
const existing = await pool.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[shortCode]
);
if (existing.rows[0]?.long_url === url) {
// Same URL was already shortened - return existing code
console.log(`[HASH] Deduplicated: ${shortCode} -> ${url}`);
res.json({
shortCode,
shortUrl: `http://localhost:3000/${shortCode}`,
});
return;
}
// Different URL with same hash - collision!
attempts++;
console.log(`[HASH] Collision on ${shortCode}, attempt ${attempts}`);
shortCode = hashToBase62(url + String(attempts));
} else {
throw err;
}
}
}
res.status(500).json({ error: "Failed to generate unique code" });
});
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
});
What does this endpoint do?
The endpoint follows a "try, detect, retry" pattern for collisions.
- It hashes the URL to produce a 7-character code, then tries to INSERT it into the database.
- If the INSERT fails with error code 23505 (PostgreSQL's unique constraint violation), it checks whether the existing row has the same URL. If so, it returns the existing code (deduplication).
- If a different URL already owns that code, it's a collision. The endpoint appends the attempt number to the input, re-hashes, and tries again (up to 5 times).
- Save src/index.ts.
Seeing red squiggles in your editor?
If your editor shows TypeScript errors on the imports, make sure you ran npm install in the previous step. The type definitions come from the installed packages.
help me fix TypeScript import errors in my URL shortener project
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/index.ts before moving on.
ⓧ I'd like to double check the full code
import express from "express";
import { createHash } from "crypto";
import pg from "pg";
import Redis from "ioredis";
const app = express();
app.use(express.json());
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
const redis = new Redis({ host: "localhost", port: 6379 });
const BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(num: number): string {
if (num === 0) return BASE62_CHARS[0];
let result = "";
while (num > 0) {
result = BASE62_CHARS[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function hashToBase62(input: string): string {
const hash = createHash("sha256").update(input).digest("hex");
const num = parseInt(hash.substring(0, 12), 16);
return toBase62(num).substring(0, 7);
}
// --- Approach 1: Hash-based ID generation ---
app.post("/api/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
let shortCode = hashToBase62(url);
let attempts = 0;
while (attempts < 5) {
try {
await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[shortCode, url]
);
console.log(
`[HASH] Created: ${shortCode} -> ${url} (attempts: ${attempts})`
);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
return;
} catch (err: any) {
if (err.code === "23505") {
const existing = await pool.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[shortCode]
);
if (existing.rows[0]?.long_url === url) {
console.log(`[HASH] Deduplicated: ${shortCode} -> ${url}`);
res.json({
shortCode,
shortUrl: `http://localhost:3000/${shortCode}`,
});
return;
}
attempts++;
console.log(`[HASH] Collision on ${shortCode}, attempt ${attempts}`);
shortCode = hashToBase62(url + String(attempts));
} else {
throw err;
}
}
}
res.status(500).json({ error: "Failed to generate unique code" });
});
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
});
Run the server and test with curl
Time to see your shortener in action. You'll start the server, shorten some URLs, and observe how hashing produces deterministic codes.
- Start the server by running this command:
npx tsx src/index.ts
- You should see URL Shortener running on http://localhost:3000 in your terminal.
- Open a second terminal window (keep the server running in the first one).
- Shorten your first URL by running this curl command:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://www.example.com/very/long/path/to/a/page"}'
- You should see a JSON response with a shortCode and shortUrl field.
- Now run the exact same curl command again with the same URL.
You'll notice the same URL produces the same short code every time. In your server terminal, you'll see the [HASH] Deduplicated log message instead of [HASH] Created.
- Try shortening a few more different URLs to see unique codes generated:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/features"}'
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://docs.docker.com/get-started/"}'
What's the trade-off with deterministic hashing?
Deterministic means the same URL always maps to the same short code. This is great for deduplication. If 1,000 users shorten the same article, your database stores it once.
The downside is privacy. If two users shorten the same URL, they get the same code. This leaks information about who else shortened it. In a production system, this might be unacceptable for private or sensitive links.
Connection refused when running curl?
Make sure your server is still running in the first terminal. If it crashed, check for any error messages about the database connection. Your Docker services must be running for the server to connect to PostgreSQL.
help me debug my URL shortener server connection
Your hash-based shortener works, but it has a fundamental limitation: collision risk scales with the number of URLs stored. Next up, you'll build a second version using counter-based Base62 IDs that eliminates collisions entirely.
Refactor to Counter-Based Base62 IDs
Your hash-based approach works, but it has a fundamental limitation: collision risk. As your database grows, the probability of two different URLs mapping to the same 7-character code increases. In production systems handling millions of URLs, collision resolution adds unpredictable latency to every write.
The alternative is a counter-based approach. Instead of hashing the URL and hoping for uniqueness, you let PostgreSQL's auto-incrementing SERIAL column generate a guaranteed-unique integer, then convert that integer to a Base62 string. Zero collisions, zero retries.
In this step, get ready to:
- Add a counter-based Base62 endpoint that eliminates collision risk entirely.
- Test both endpoints with the same URLs to observe the behavioral differences.
- Document the trade-offs between hash-based and counter-based ID generation for interview conversations.
Add the counter-based Base62 endpoint
The counter-based strategy works in two phases. First, you insert a row with a temporary placeholder to claim a unique SERIAL ID from PostgreSQL. Then you convert that integer ID into a Base62 string and update the row with the final short code.
Why INSERT then UPDATE?
The toBase62 function you already wrote handles the integer-to-Base62 conversion. The trick is getting a guaranteed-unique integer. PostgreSQL's SERIAL column auto-increments on every INSERT, so you insert first to claim the ID, then compute the code from that ID.
This two-step pattern is intentionally simple for learning. A production system would use pre-allocated ID ranges via a coordination service like ZooKeeper or etcd to avoid the extra UPDATE round-trip.
- In src/index.ts, find the closing }); of the app.post("/api/shorten", ...) handler.
- Add the following counter-based endpoint below it (before app.listen):
// --- Approach 2: Counter-based Base62 ID generation ---
app.post("/api/v2/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
const result = await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id",
["placeholder", url]
);
const id = result.rows[0].id;
const shortCode = toBase62(id).padStart(7, "0");
await pool.query("UPDATE urls SET short_code = $1 WHERE id = $2", [
shortCode,
id,
]);
console.log(`[COUNTER] Created: ${shortCode} -> ${url} (id: ${id})`);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
});
What does this code do?
- INSERT INTO urls ... RETURNING id inserts a row with a temporary "placeholder" short code. PostgreSQL returns the auto-generated id immediately.
- toBase62(id).padStart(7, "0") converts the integer ID into a 7-character Base62 string, padding with leading zeros if needed.
- The second UPDATE query replaces the placeholder with the computed short code.
- The [COUNTER] log prefix makes it easy to distinguish these entries from [HASH] entries in your terminal.
- Save src/index.ts.
- Stop the running server with Ctrl+C and restart it by running:
npx tsx src/index.ts
- You should see URL Shortener running on http://localhost:3000 confirming the server started without errors.
Seeing a unique constraint error on startup?
If you previously shortened URLs in Step 2 and still have rows in the database, the "placeholder" value might conflict with an existing short_code if you run the counter endpoint multiple times without the UPDATE completing. This resolves itself because the UPDATE immediately replaces the placeholder.
If you need a fresh start, run npx tsx src/init-db.ts after dropping the table with docker compose exec postgres psql -U postgres -d urlshortener -c "DROP TABLE urls".
help me fix a database constraint error in my URL shortener
Test both endpoints with the same URLs
Now you can observe the key behavioral difference between the two approaches by shortening the same URL through each endpoint.
- Open a second terminal window.
- Shorten a URL using the hash-based endpoint by running:
curl -X POST http://localhost:3000/api/shorten \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/my-long-article"}'
- Now shorten the same URL using the counter-based endpoint by running:
curl -X POST http://localhost:3000/api/v2/shorten \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/my-long-article"}'
- Run the v2 command again with the exact same URL:
curl -X POST http://localhost:3000/api/v2/shorten \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/my-long-article"}'
Check the terminal running your server. You should see something like this:
What should I see?
- The [HASH] entry shows the same short code each time you submit the same URL. It deduplicates automatically.
- The [COUNTER] entries each show a different short code, even for the same URL. Each submission creates a new entry with a new ID.
- The counter approach logged zero collision attempts. No retries were needed.
- Try shortening 3-4 more different URLs through both endpoints to confirm the pattern holds.
Not seeing any response from curl?
Make sure your server is still running in the first terminal window. Check that you are using port 3000 in the URL and that the path is /api/v2/shorten (not /api/shorten).
help me debug why my curl requests to the URL shortener are not returning responses
Compare the two approaches
You now have concrete evidence of how each strategy behaves. This is the kind of reasoning that separates strong system design answers from generic ones.
How do these approaches compare?
- Counter-based (v2) is collision-free and faster because there is no retry loop. The trade-off: codes are sequential and predictable, and submitting the same URL twice produces different codes (no deduplication).
- Hash-based (v1) is deterministic. The same URL always maps to the same code. The trade-off: collision risk grows with database size (~1 in 35,000 at 7 chars with 68M entries), and each collision adds a database round-trip.
What should I say in an interview?
Counter + Base62 wins at scale because collision resolution adds unpredictable latency to the write path. At FAANG scale, even a small probability of collisions multiplied across millions of writes per day creates a long tail of slow requests.
The sequential/predictable nature of counter-based codes is solvable. Mention that production systems use pre-allocated ID ranges (via ZooKeeper or etcd) distributed across multiple application servers, so no single counter becomes a bottleneck and codes are not trivially sequential across the fleet.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts and confirmed both endpoints respond.
ⓧ I'd like to double check the full code
Here is the complete src/index.ts at this point:
import express from "express";
import { createHash } from "crypto";
import pg from "pg";
import Redis from "ioredis";
const app = express();
app.use(express.json());
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
const redis = new Redis({ host: "localhost", port: 6379 });
const BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(num: number): string {
if (num === 0) return BASE62_CHARS[0];
let result = "";
while (num > 0) {
result = BASE62_CHARS[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function hashToBase62(input: string): string {
const hash = createHash("sha256").update(input).digest("hex");
const num = parseInt(hash.substring(0, 12), 16);
return toBase62(num).substring(0, 7);
}
// --- Approach 1: Hash-based ID generation ---
app.post("/api/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
let shortCode = hashToBase62(url);
let attempts = 0;
while (attempts < 5) {
try {
await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[shortCode, url]
);
console.log(
`[HASH] Created: ${shortCode} -> ${url} (attempts: ${attempts})`
);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
return;
} catch (err: any) {
if (err.code === "23505") {
const existing = await pool.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[shortCode]
);
if (existing.rows[0]?.long_url === url) {
console.log(`[HASH] Deduplicated: ${shortCode} -> ${url}`);
res.json({
shortCode,
shortUrl: `http://localhost:3000/${shortCode}`,
});
return;
}
attempts++;
console.log(`[HASH] Collision on ${shortCode}, attempt ${attempts}`);
shortCode = hashToBase62(url + String(attempts));
} else {
throw err;
}
}
}
res.status(500).json({ error: "Failed to generate unique code" });
});
// --- Approach 2: Counter-based Base62 ID generation ---
app.post("/api/v2/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
const result = await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id",
["placeholder", url]
);
const id = result.rows[0].id;
const shortCode = toBase62(id).padStart(7, "0");
await pool.query("UPDATE urls SET short_code = $1 WHERE id = $2", [
shortCode,
id,
]);
console.log(`[COUNTER] Created: ${shortCode} -> ${url} (id: ${id})`);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
});
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
});
You now have two working ID generation strategies side by side, with concrete evidence of how they behave differently. Next up, you will build the read path and add a Redis cache-aside layer to see how caching dramatically reduces redirect latency.
Add the Read Path with Redis Cache-Aside
You have a working write path that creates short URLs. Now it's time to build the read path, which is where the real performance challenge lives.
URL shorteners are read-heavy systems. Reads outnumber writes roughly 100:1. Every time someone clicks a short link, your server must look up the original URL and redirect the user. In this step, you'll implement that redirect with a Redis cache-aside layer so you can measure the latency difference yourself.
In this step, get ready to:
- Implement a GET /:code redirect endpoint with Redis cache-aside.
- Add a health check endpoint to verify database and Redis connectivity.
- Test the latency difference between cache hits and cache misses.
Implement the redirect endpoint with cache-aside
The cache-aside pattern checks Redis first for a cached result. On a hit, the response fires immediately without touching the database. On a miss, it queries PostgreSQL, stores the result in Redis for next time, then responds.
- In src/index.ts, find the closing }); of the POST /api/v2/shorten handler.
- Add the following redirect endpoint below it:
// --- Read path: Redirect with cache-aside ---
app.get("/:code", async (req, res) => {
const { code } = req.params;
// Check Redis cache first
const cached = await redis.get(`url:${code}`);
if (cached) {
console.log(`[CACHE HIT] ${code}`);
// Increment clicks in background (non-blocking)
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, cached);
return;
}
// Cache miss - query database
console.time(`[DB QUERY] ${code}`);
const result = await pool.query(
"SELECT long_url, expires_at FROM urls WHERE short_code = $1",
[code]
);
console.timeEnd(`[DB QUERY] ${code}`);
if (result.rows.length === 0) {
res.status(404).json({ error: "Short URL not found" });
return;
}
const { long_url, expires_at } = result.rows[0];
// Check expiration
if (expires_at && new Date(expires_at) < new Date()) {
await redis.del(`url:${code}`);
res.status(410).json({ error: "This link has expired" });
return;
}
// Populate cache with 24-hour TTL
await redis.set(`url:${code}`, long_url, "EX", 86400);
console.log(`[CACHE MISS] ${code} - populated cache`);
// Increment clicks in background
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, long_url);
});
What does this code do?
This handler runs every time someone visits a short URL. It follows the cache-aside pattern in two branches:
- Cache hit path: redis.get(`url:${code}`) returns the long URL instantly. The server redirects with a 302, and increments clicks without waiting (no await). This keeps redirect latency as low as possible.
- Cache miss path: The query hits PostgreSQL (timed with console.time/console.timeEnd). After the lookup, the result is stored in Redis with a 24-hour TTL (EX 86400) so the next request gets the fast path.
- Expiration check: If expires_at is in the past, the endpoint returns a 410 Gone status and clears the stale cache entry. This prepares the system for the Secret Mission later.
Why cache-aside instead of write-through?
With write-through, every shorten request would populate the cache immediately. That wastes memory on URLs that may never be clicked.
Cache-aside only caches URLs on the first read. Popular URLs (the ones that matter) end up cached quickly. Unpopular URLs never waste cache space. For a read-heavy workload where a small percentage of links receive most traffic, this is the better fit.
- Save src/index.ts.
You won't test this yet. The next chunk adds a health check, then you'll restart the server and test both together.
Seeing a red squiggly under res.redirect?
Make sure you're using Express 5.2.1 (check your package.json). Express 5 handles async route handlers natively. If you accidentally installed Express 4, the types differ.
help me debug a TypeScript error with Express redirect
Add a health check endpoint
A health check gives you a quick way to verify both PostgreSQL and Redis are reachable without digging through logs.
- In src/index.ts, find the app.listen(3000, line near the bottom of the file.
- Add this health check endpoint directly above the app.listen call:
// --- Health check ---
app.get("/api/health", async (_req, res) => {
const dbOk = await pool.query("SELECT 1").then(() => true).catch(() => false);
const redisOk = await redis.ping().then(() => true).catch(() => false);
res.json({ db: dbOk, redis: redisOk });
});
What does this code do?
The /api/health endpoint pings both PostgreSQL and Redis, returning true or false for each. This is useful for quickly confirming your Docker services are up before running tests.
- Now update the app.listen callback. Find this line:
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
});
- Add a second console.log line inside the callback so it looks like this:
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
console.log("Trade-off lab ready. Try POST /api/shorten and POST /api/v2/shorten");
});
- Save src/index.ts.
- Stop your running server with Ctrl+C and restart it by running:
npx tsx src/index.ts
- Verify the health check by running:
curl http://localhost:3000/api/health
You should see {"db":true,"redis":true} in the response. Both services are reachable.
Seeing false for db or redis?
Make sure your Docker containers are still running. Run docker compose ps in a separate terminal to check their status. If they stopped, run docker compose up -d to restart them.
help me troubleshoot my Docker containers not connecting
Test cache hits vs. cache misses
Now you'll hit the same short URL twice and watch the server logs to see the latency difference between a cache miss (database query) and a cache hit (Redis lookup).
- Pick one of the short codes you created in the previous steps. You can find one by checking your earlier terminal output for a line like [HASH] Created: abc1234 or [COUNTER] Created: 0000001.
- Hit the redirect endpoint with that code by running (replace the code with your own):
curl http://localhost:3000/[[SHORTCODE="your short code from earlier, e.g. 0000001"]]
- Check your server terminal. You should see a [DB QUERY] timing line (showing milliseconds) followed by [CACHE MISS]. This is the slow path hitting PostgreSQL.
- Run the exact same curl command again:
curl http://localhost:3000/[[SHORTCODE="your short code from earlier, e.g. 0000001"]]
- Check your server terminal again. This time you should see [CACHE HIT] with no DB query timing. The redirect came straight from Redis.
Why is the difference so dramatic?
Redis serves data from memory. PostgreSQL reads from disk (even with its own buffer cache, there's query parsing and network overhead). At scale with a 95%+ cache hit rate, this means the database is nearly idle on the read path.
The key interview insight: for a URL shortener handling thousands of redirects per second, the database only needs to handle the initial cache-miss for each URL. After that, Redis takes over entirely.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/index.ts and your server is running without errors.
ⓧ I'd like to double check the full code
import express from "express";
import { createHash } from "crypto";
import pg from "pg";
import Redis from "ioredis";
const app = express();
app.use(express.json());
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
const redis = new Redis({ host: "localhost", port: 6379 });
const BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(num: number): string {
if (num === 0) return BASE62_CHARS[0];
let result = "";
while (num > 0) {
result = BASE62_CHARS[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function hashToBase62(input: string): string {
const hash = createHash("sha256").update(input).digest("hex");
const num = parseInt(hash.substring(0, 12), 16);
return toBase62(num).substring(0, 7);
}
// --- Approach 1: Hash-based ID generation ---
app.post("/api/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
let shortCode = hashToBase62(url);
let attempts = 0;
while (attempts < 5) {
try {
await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[shortCode, url]
);
console.log(
`[HASH] Created: ${shortCode} -> ${url} (attempts: ${attempts})`
);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
return;
} catch (err: any) {
if (err.code === "23505") {
const existing = await pool.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[shortCode]
);
if (existing.rows[0]?.long_url === url) {
console.log(`[HASH] Deduplicated: ${shortCode} -> ${url}`);
res.json({
shortCode,
shortUrl: `http://localhost:3000/${shortCode}`,
});
return;
}
attempts++;
console.log(`[HASH] Collision on ${shortCode}, attempt ${attempts}`);
shortCode = hashToBase62(url + String(attempts));
} else {
throw err;
}
}
}
res.status(500).json({ error: "Failed to generate unique code" });
});
// --- Approach 2: Counter-based Base62 ID generation ---
app.post("/api/v2/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
const result = await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id",
["placeholder", url]
);
const id = result.rows[0].id;
const shortCode = toBase62(id).padStart(7, "0");
await pool.query("UPDATE urls SET short_code = $1 WHERE id = $2", [
shortCode,
id,
]);
console.log(`[COUNTER] Created: ${shortCode} -> ${url} (id: ${id})`);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
});
// --- Read path: Redirect with cache-aside ---
app.get("/:code", async (req, res) => {
const { code } = req.params;
const cached = await redis.get(`url:${code}`);
if (cached) {
console.log(`[CACHE HIT] ${code}`);
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, cached);
return;
}
console.time(`[DB QUERY] ${code}`);
const result = await pool.query(
"SELECT long_url, expires_at FROM urls WHERE short_code = $1",
[code]
);
console.timeEnd(`[DB QUERY] ${code}`);
if (result.rows.length === 0) {
res.status(404).json({ error: "Short URL not found" });
return;
}
const { long_url, expires_at } = result.rows[0];
if (expires_at && new Date(expires_at) < new Date()) {
await redis.del(`url:${code}`);
res.status(410).json({ error: "This link has expired" });
return;
}
await redis.set(`url:${code}`, long_url, "EX", 86400);
console.log(`[CACHE MISS] ${code} - populated cache`);
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, long_url);
});
// --- Health check ---
app.get("/api/health", async (_req, res) => {
const dbOk = await pool.query("SELECT 1").then(() => true).catch(() => false);
const redisOk = await redis.ping().then(() => true).catch(() => false);
res.json({ db: dbOk, redis: redisOk });
});
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
console.log("Trade-off lab ready. Try POST /api/shorten and POST /api/v2/shorten");
});
You now have measurable proof that caching works. In the next step, you'll explore the other critical read-path trade-off: whether to use 301 or 302 redirects, and what each choice costs you.
Explore 301 vs. 302 Redirect Trade-offs
Your cache-aside layer is working. You can serve redirects from Redis in a fraction of the time a database lookup takes.
But there is another dimension to redirect performance that has nothing to do with your server: the HTTP status code itself. A 301 tells the browser to cache the destination permanently, so it never asks your server again. A 302 makes the browser check back every time. This distinction determines whether you can track clicks at all.
In this step, get ready to:
- Switch your redirect to 301 and observe the browser caching the destination.
- Switch back to 302 and confirm that every click hits the server.
- Document the decision framework for choosing between 301 and 302.
Change the redirect to 301 and observe browser caching
A 301 status code means "Moved Permanently." When the browser receives a 301, it stores the redirect destination in its own local cache. On subsequent visits to the same short URL, the browser redirects itself without ever contacting your server.
- In src/index.ts, find the redirect line inside the cache-hit block of the GET /:code handler:
res.redirect(302, cached);
- Change 302 to 301, so the line looks like this:
res.redirect(301, cached);
- Find the second redirect near the bottom of the same handler (the cache-miss path):
res.redirect(302, long_url);
- Change that 302 to 301 as well:
res.redirect(301, long_url);
- Save src/index.ts.
- Stop your running server with Ctrl+C in the terminal, then restart it by running:
npx tsx src/index.ts
Now test the 301 behavior in your browser.
- Open a new Incognito or Private browser window (this ensures no prior cached redirects interfere).
- Open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Click the Network tab at the top of DevTools.
- Paste one of your short URLs into the address bar (for example http://localhost:3000/ followed by a short code you created earlier) and press Enter.
- Check your terminal. You should see a server log line (either [CACHE HIT] or [CACHE MISS]) confirming the first request reached the server.
- Now paste the same short URL into the address bar again and press Enter.
- Check your terminal again. This time there is no new log line. The browser used its own cached 301 redirect and never contacted your server.
Why did the second request skip your server?
When the browser receives a 301 response, it permanently stores the redirect mapping. On the second visit, the browser resolves the redirect internally without making any network request. Your server receives zero traffic for that short URL going forward.
This means your clicks counter in the database stops incrementing. You have lost all analytics visibility for that link.
- Verify the click count did not increment by running this in a new terminal tab:
docker compose exec postgres psql -U postgres -d urlshortener -c "SELECT short_code, clicks FROM urls ORDER BY id DESC LIMIT 5"
The clicks value for the short code you visited twice should show 1, not 2. The second visit was invisible to your server.
Still seeing server logs on the second visit?
Make sure you are pasting the URL into the address bar and pressing Enter, not using the browser's back button or a hard refresh. A hard refresh (Cmd+Shift+R or Ctrl+Shift+R) bypasses the browser cache and forces a network request.
Also confirm you changed both res.redirect calls in the file (cache-hit path and cache-miss path).
Help me debug why my browser is not caching the 301 redirect
Switch back to 302 and confirm analytics work
A 302 status code means "Found" (temporary redirect). The browser treats this as a one-time instruction and contacts your server every single time the user clicks the link.
- In src/index.ts, change both 301 values back to 302. The cache-hit line should read:
res.redirect(302, cached);
- The cache-miss line should read:
res.redirect(302, long_url);
- Save src/index.ts.
- Stop and restart the server by running:
npx tsx src/index.ts
- Open a new Incognito or Private browser window (to clear any cached 301 from the previous test).
- Open the Network tab in DevTools.
- Visit the same short URL. Check your terminal for a log line.
- Visit the same short URL again. Check your terminal for a second log line.
Every click now reaches your server. The clicks counter increments correctly each time.
- Confirm the click count incremented by running:
docker compose exec postgres psql -U postgres -d urlshortener -c "SELECT short_code, clicks FROM urls ORDER BY id DESC LIMIT 5"
The clicks value for the short code you visited should now reflect the actual number of times you visited it.
What is the fundamental 301 vs. 302 trade-off?
301 reduces server load because the browser handles repeats locally. But you lose all analytics. Every repeat visit is invisible to you.
302 preserves analytics because every click costs a server round-trip. You see every visit. The trade-off is that your server handles more traffic.
Click count not incrementing with 302?
If you reuse the same browser window from the 301 test, the browser may still have the permanent redirect cached. Always open a fresh Incognito or Private window when switching between 301 and 302 tests.
Help me figure out why my click counter is not incrementing
Document the decision framework
Now that you have observed both behaviors firsthand, you can articulate the decision framework that interviewers expect.
When to use 302 vs. 301
Use 302 when you need analytics. This covers most URL shorteners, marketing links, and any scenario where you track clicks, run A/B tests, or need the ability to change the destination later.
Use 301 only for permanent, static redirects where tracking does not matter. Examples include domain migrations or canonical URL consolidation.
The middle path (what Bitly uses in production) is 301 with Cache-Control: private, max-age=90. This gives the browser a short 90-second cache window while still capturing most clicks. After 90 seconds the browser checks back with the server, so you get analytics on any visit spaced more than 90 seconds apart.
The interview-ready answer
When asked "301 or 302?" in a system design interview, lead with the requirement. If the interviewer says "we need analytics," the answer is 302. If they push on performance, mention Bitly's approach: 301 with a short max-age as a compromise. This shows you understand both the trade-off and a real-world mitigation.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts with both redirects set back to 302.
ⓧ I'd like to double check the full code
import express from "express";
import { createHash } from "crypto";
import pg from "pg";
import Redis from "ioredis";
const app = express();
app.use(express.json());
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "urlshortener",
user: "postgres",
password: "postgres",
});
const redis = new Redis({ host: "localhost", port: 6379 });
const BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(num: number): string {
if (num === 0) return BASE62_CHARS[0];
let result = "";
while (num > 0) {
result = BASE62_CHARS[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function hashToBase62(input: string): string {
const hash = createHash("sha256").update(input).digest("hex");
const num = parseInt(hash.substring(0, 12), 16);
return toBase62(num).substring(0, 7);
}
// --- Approach 1: Hash-based ID generation ---
app.post("/api/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
let shortCode = hashToBase62(url);
let attempts = 0;
while (attempts < 5) {
try {
await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[shortCode, url]
);
console.log(
`[HASH] Created: ${shortCode} -> ${url} (attempts: ${attempts})`
);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
return;
} catch (err: any) {
if (err.code === "23505") {
const existing = await pool.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[shortCode]
);
if (existing.rows[0]?.long_url === url) {
console.log(`[HASH] Deduplicated: ${shortCode} -> ${url}`);
res.json({
shortCode,
shortUrl: `http://localhost:3000/${shortCode}`,
});
return;
}
attempts++;
console.log(`[HASH] Collision on ${shortCode}, attempt ${attempts}`);
shortCode = hashToBase62(url + String(attempts));
} else {
throw err;
}
}
}
res.status(500).json({ error: "Failed to generate unique code" });
});
// --- Approach 2: Counter-based Base62 ID generation ---
app.post("/api/v2/shorten", async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: "url is required" });
return;
}
const result = await pool.query(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id",
["placeholder", url]
);
const id = result.rows[0].id;
const shortCode = toBase62(id).padStart(7, "0");
await pool.query("UPDATE urls SET short_code = $1 WHERE id = $2", [
shortCode,
id,
]);
console.log(`[COUNTER] Created: ${shortCode} -> ${url} (id: ${id})`);
res.json({ shortCode, shortUrl: `http://localhost:3000/${shortCode}` });
});
// --- Read path: Redirect with cache-aside ---
app.get("/:code", async (req, res) => {
const { code } = req.params;
const cached = await redis.get(`url:${code}`);
if (cached) {
console.log(`[CACHE HIT] ${code}`);
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, cached);
return;
}
console.time(`[DB QUERY] ${code}`);
const result = await pool.query(
"SELECT long_url, expires_at FROM urls WHERE short_code = $1",
[code]
);
console.timeEnd(`[DB QUERY] ${code}`);
if (result.rows.length === 0) {
res.status(404).json({ error: "Short URL not found" });
return;
}
const { long_url, expires_at } = result.rows[0];
if (expires_at && new Date(expires_at) < new Date()) {
await redis.del(`url:${code}`);
res.status(410).json({ error: "This link has expired" });
return;
}
await redis.set(`url:${code}`, long_url, "EX", 86400);
console.log(`[CACHE MISS] ${code} - populated cache`);
pool.query("UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1", [
code,
]);
res.redirect(302, long_url);
});
// --- Health check ---
app.get("/api/health", async (_req, res) => {
const dbOk = await pool.query("SELECT 1").then(() => true).catch(() => false);
const redisOk = await redis.ping().then(() => true).catch(() => false);
res.json({ db: dbOk, redis: redisOk });
});
app.listen(3000, () => {
console.log("URL Shortener running on http://localhost:3000");
console.log("Trade-off lab ready. Try POST /api/shorten and POST /api/v2/shorten");
});
You have now experienced the 301 vs. 302 trade-off firsthand and can articulate the decision framework with confidence. Up next, the Secret Mission challenges you with a concurrency problem that most interview candidates miss.
Secret mission
Cache Stampede Protection with TTL Expiration
What happens when a popular shortened link expires and 100 users click it at the same time? Without protection, all 100 requests slam your database simultaneously. This is the cache stampede problem, and most interview candidates miss it entirely. In this mission, you'll implement a Redis mutex lock that ensures only one request ever hits the database, no matter how many arrive concurrently.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container (stores your URL mappings and click data).
- Redis Docker container (caching layer for redirect lookups).
- Docker volume pgdata (persists PostgreSQL data between restarts).
- Local project folder with TypeScript source files, node_modules, and configuration.
Keep everything running
No action needed. Choose this if you're still actively experimenting with the trade-offs or plan to extend the project further.
- Your PostgreSQL and Redis containers will continue running until you manually stop them or restart Docker.
- There is zero cost since everything runs on your local machine.
- Your URL mappings and click data persist in the pgdata volume.
Pause - I'll come back to this later
Stop the running containers to free up system memory and CPU while preserving all your data.
- Open your terminal and navigate to your project folder.
- Stop the containers by running:
docker compose stop
- Your data stays intact in the Docker volume. When you're ready to resume, run docker compose up -d from the same folder.
Delete - I don't want to use this again
Remove all Docker containers, volumes, and local project files to start completely fresh.
- Open your terminal and navigate to your project folder.
- Remove the Docker containers and the named volume by running:
docker compose down -v
- This removes the PostgreSQL container, the Redis container, and the pgdata volume with all stored URL data.
- Delete the project folder by navigating one level up and running:
macOS
cd ..
rm -rf url-shortener-system-design
Windows
cd ..
rmdir /s /q url-shortener-system-design
- This removes all source files, node_modules, and configuration.
That's a Wrap!
That's a Wrap!
Nice work! You just built a URL shortener twice, each time with different trade-offs, and you can now articulate exactly why one approach beats another at scale.
You've learned how to:
- Build a URL shortener API in TypeScript with Express, PostgreSQL, and Redis, implementing and comparing two ID generation strategies: hash-based (collision-prone, deterministic) vs. counter-based Base62 (collision-free, sequential).
- Add a Redis cache-aside layer and measure the latency difference between cache hits and database lookups, proving why read-heavy systems depend on caching.
- Explore 301 vs. 302 redirect semantics and document the analytics vs. performance trade-off that every URL shortener must navigate.
- Secret Mission: Implement cache stampede protection using a Redis mutex lock for expired link scenarios, ensuring only one database query fires under concurrent load.
Ready to quiz yourself?