Build a URL Shortener API
Build a URL shortener with Redis caching to master system design interviews.
Introduction
30 Second Summary
Every time you click a shortened link on social media or in a text message, a system instantly translates that tiny code back to the full web address. It's one of the most common system design interview questions because it packs caching, database design, and encoding trade-offs into a deceptively simple problem.
In this project, you will build a fully functional URL shortener API from scratch using Express, PostgreSQL, and Redis. You'll experience firsthand why read-heavy systems need caching and how different ID generation strategies handle collisions.
What You'll Build
You paste a long URL into your API, get back a short code, and visiting that code in the browser redirects you to the original destination. You can show it off to anyone with a terminal and a browser.
By the end of this project, you'll have:
- A running URL shortener API where you POST any long URL and get back a short code like localhost:3000/abc123 that redirects to the original.
- Measurable proof of the cache-aside pattern in action. Your first redirect hits the database, and subsequent ones are served from Redis in under 1ms.
- A clear mental model of two ID generation strategies. You'll observe hash-based encoding (collision-prone) vs counter-based Base62 encoding (collision-free) and know when each matters.
- Secret Mission: Add click analytics tracking and a stats endpoint that shows how many times each short URL has been visited.
Are there any prerequisites?
You should have basic familiarity with JavaScript and Node.js (variables, functions, callbacks). You also need Docker installed on your machine. Docker handles all the database setup so you don't need to install PostgreSQL or Redis locally.
Before We Start
Before We Start...
Before we write any code, let's lock in what you're about to build and why it matters. A URL shortener is the single most common system design interview question, and building one from scratch gives you hands-on experience with caching, encoding strategies, and redirect semantics.
Set Up the Docker Environment
Your URL shortener needs three services working together: a Node.js server to handle requests, PostgreSQL to store URL mappings, and Redis to cache frequently accessed redirects.
Docker Compose lets you define all three in a single configuration file and launch them with one command. This means you won't need to install PostgreSQL or Redis directly on your machine.
In this step, get ready to:
- Create the project directory with pinned dependencies.
- Configure Docker to run Node.js, PostgreSQL, and Redis as linked services.
- Build and start all three containers with a single command.
Create the project directory and package file
- 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
cd url-shortener
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the url-shortener folder on your Desktop, then click Open.
Your URL shortener won't use Node.js installed on your machine. Instead, Docker handles Node.js inside a container. You'll create the package.json file manually so Docker knows which dependencies to install.
- Create a file called package.json in the root of your url-shortener folder and paste in the following:
{
"name": "url-shortener",
"version": "1.0.0",
"description": "URL shortener for system design interview prep",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node test.js"
},
"dependencies": {
"express": "5.1.0",
"ioredis": "5.6.1",
"pg": "8.16.0"
}
}
What do these dependencies do?
- express 5.1.0 is the web framework that handles incoming HTTP requests and routes.
- pg 8.16.0 is the PostgreSQL client library for running SQL queries from Node.js.
- ioredis 5.6.1 is the Redis client that handles caching with a clean get/set API.
- Create a file called Dockerfile (no extension) in the same folder and paste in the following:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
What does this Dockerfile do?
This file tells Docker how to build your Node.js container.
- FROM node:22-alpine starts from a lightweight Node.js 22 image.
- COPY package.json then RUN npm install installs your dependencies inside the container.
- COPY . . brings in the rest of your source code.
- CMD ["npm", "start"] tells the container to run your Express server on launch.
- Confirm the files exist by running:
ls
You should see Dockerfile and package.json in the output.
Don't see both files?
- Make sure you saved both files in VS Code. Unsaved files show a white dot on their tab.
- Confirm your terminal is inside the url-shortener folder by checking the path shown in your terminal prompt.
- help me find my project files
Define the Docker Compose services
Docker Compose orchestrates multiple containers as a single application. Your docker-compose.yml file defines the three services and how they connect to each other.
- Create a file called docker-compose.yml in your url-shortener folder. Start with the app and database services:
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/urlshortener
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
- .:/app
- /app/node_modules
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=urlshortener
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
What are these two services?
- The app service builds your Node.js container from the Dockerfile. It passes connection strings for PostgreSQL and Redis as environment variables.
- depends_on with service_healthy means the app waits until PostgreSQL passes its health check before starting.
- The postgres service uses the official postgres:16-alpine image and runs pg_isready every 5 seconds to report its health status.
- The volumes mount init.sql into the PostgreSQL startup scripts folder. This is where you'll define your table schema in the next step.
- Below the postgres service (still inside the same docker-compose.yml file), add the Redis service and volume declaration:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
What does this section add?
- The redis service uses the lightweight redis:7-alpine image and exposes port 6379 for cache operations.
- The top-level volumes: section declares pgdata so PostgreSQL data persists even if you restart the containers.
The docker-compose.yml references an init.sql file that doesn't exist yet. You'll add the schema in the next step, but Docker needs the file to exist now.
- Create an empty placeholder by running:
touch init.sql
- Validate your Docker Compose configuration by running:
docker compose config --quiet
If the file is valid, the command returns no output. If there's a syntax error, it prints the problem.
Seeing a YAML error?
- YAML is indentation-sensitive. Each nested level uses exactly 2 spaces, never tabs. Check that services: starts at column 0 and each service name is indented 2 spaces.
- Make sure the redis service is at the same indentation level as app and postgres.
- Confirm volumes: at the bottom is at column 0 (not indented under services).
- help me fix my docker-compose.yml
✔️ Awesome, I've got everything!
Great. Make sure you've saved docker-compose.yml before moving on.
ⓧ I'd like to double check the full code
Your docker-compose.yml should contain these sections in order. Scroll up and confirm each is present:
- The services: key at the top.
- The app service with build, ports, environment, depends_on, and volumes.
- The postgres service with image, environment, volumes, and healthcheck.
- The redis service with image and ports.
- A top-level volumes: block declaring pgdata.
Create the Express server and start everything
The last piece is the actual server code. This Express server connects to both PostgreSQL and Redis on startup and exposes a health check endpoint.
- Create a file called index.js in your url-shortener folder and paste in the following:
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
app.use(express.json());
// Connect to PostgreSQL using the connection string from Docker environment
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Connect to Redis using the URL from Docker environment
const redis = new Redis(process.env.REDIS_URL);
// Health check endpoint to verify the server is running
app.get('/', (req, res) => {
res.json({ status: 'ok', message: 'URL Shortener is running' });
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- Pool creates a connection pool to PostgreSQL. It reads the DATABASE_URL environment variable that Docker Compose passes in.
- new Redis(process.env.REDIS_URL) connects to the Redis container using the URL Docker Compose injects.
- The GET / route returns a simple JSON response so you can verify the server is alive.
- Save index.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before launching Docker.
ⓧ I'd like to double check the full code
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const redis = new Redis(process.env.REDIS_URL);
app.get('/', (req, res) => {
res.json({ status: 'ok', message: 'URL Shortener is running' });
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Before you run the next command, what do you think will happen? Docker needs to build an image, install dependencies, start PostgreSQL, wait for its health check, start Redis, and finally launch your Express app.
- Build and start all three containers by running:
docker compose up --build
What should I see?
Docker downloads the base images on the first run (this takes 1-2 minutes). Then it builds your app image, installs npm dependencies, and starts the containers.
You'll see logs from all three services streamed to your terminal. Once PostgreSQL passes its health check, your Express app starts and prints Server running on port 3000.
Once you see Server running on port 3000 in the terminal output, your three-container stack is live.
- Open a new terminal tab (Cmd+T in macOS Terminal, or Ctrl+Shift+` in VS Code) and test the health check by running:
curl http://localhost:3000/
You should see this JSON response:
{"status":"ok","message":"URL Shortener is running"}
Not seeing 'Server running on port 3000'?
- If you see "port is already allocated", another process is using port 3000. Stop it with lsof -i :3000 (macOS) to find the PID, then kill <PID>.
- If PostgreSQL logs show "FATAL: database urlshortener does not exist", delete the volume with docker compose down -v and run docker compose up --build again.
- If you see npm errors during the build, check that your package.json has valid JSON (no trailing commas, correct quotes).
- help me get my Docker containers running
Why use Docker instead of installing databases locally?
Installing PostgreSQL and Redis directly on your machine means dealing with OS-specific install steps, version conflicts, and cleanup headaches.
Docker gives you identical environments every time. When you're done, docker compose down -v removes everything cleanly. This is especially important for system design projects where you experiment with multiple services.
Your three-container environment is running. Next up, you'll build the actual URL shortening logic with a hash-based approach and see its critical flaw firsthand.
Build the Naive Shortener (Hash-Based)
Your Docker environment is running and the health check proves all three containers are connected.
Now it's time to make this thing actually shorten URLs. The simplest approach is to hash the incoming URL with SHA-256 and take the first 7 characters as the short code. This gets a working API fast, but it has a critical flaw that interviewers love to ask about: collisions.
In this step, get ready to:
- Create the PostgreSQL table schema to store URL mappings.
- Build a POST endpoint that hashes URLs into short codes.
- Build a GET endpoint that redirects short codes to their original URLs.
Create the database schema
Before your app can store URL mappings, PostgreSQL needs a table with the right columns. You'll create an initialization script that runs automatically when the database container starts for the first time.
- Create a new file called init.sql in your project folder (the same folder as index.js) and paste the following schema:
CREATE TABLE IF NOT EXISTS urls (
id SERIAL PRIMARY KEY,
short_code VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_short_code ON urls(short_code);
What does this schema do?
- id SERIAL PRIMARY KEY auto-generates a unique numeric ID for every row.
- short_code VARCHAR(10) UNIQUE NOT NULL stores the 7-character hash and ensures no two rows share the same code.
- The idx_short_code index makes lookups by short code fast, which matters because every redirect queries this column.
- Save init.sql.
How does PostgreSQL pick up this file?
Your docker-compose.yml already mounts init.sql into the container's /docker-entrypoint-initdb.d/ directory. PostgreSQL runs every .sql file in that directory the first time the database volume is created.
Build the POST /shorten endpoint
This endpoint receives a long URL, hashes it with SHA-256, truncates the hash to 7 characters, and stores the mapping in PostgreSQL. If two different URLs produce the same 7-character code (a collision), it appends a counter and rehashes until it finds a unique code.
- In index.js, find this line near the top of the file:
const Redis = require('ioredis');
- Add this line directly below it to import Node's built-in crypto module:
const crypto = require('crypto');
- Now find the health check route (the app.get('/') block). Add the following POST endpoint directly below it:
app.post('/shorten', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
try {
// Hash the URL with SHA-256 and take the first 7 hex characters
let shortCode = crypto.createHash('sha256').update(url).digest('hex').slice(0, 7);
let counter = 0;
// Collision check: if this code already exists, rehash with a counter
while (true) {
const existing = await pool.query('SELECT id FROM urls WHERE short_code = $1', [shortCode]);
if (existing.rows.length === 0) break;
counter++;
shortCode = crypto.createHash('sha256').update(url + counter).digest('hex').slice(0, 7);
}
// Store the mapping in PostgreSQL
await pool.query('INSERT INTO urls (short_code, original_url) VALUES ($1, $2)', [shortCode, url]);
res.json({ short_url: `http://localhost:3000/${shortCode}`, short_code: shortCode });
} catch (error) {
console.error('[ERROR] Failed to shorten URL:', error.message);
res.status(500).json({ error: 'Failed to shorten URL' });
}
});
What does this code do?
- crypto.createHash('sha256').update(url).digest('hex') produces a 64-character hex string from the URL. Taking the first 7 characters gives us the short code.
- The while (true) loop handles collisions. If a code is already taken, it appends an incrementing counter to the URL and hashes again until it finds a unique code.
- This approach is deterministic. The same URL always produces the same hash, so shortening the same URL twice returns the same code.
- Save index.js.
Since you created a new database schema, you need to rebuild the containers with a fresh PostgreSQL volume so the init script runs.
- Stop your running containers by pressing Ctrl+C in the terminal where they're running.
- Remove the old containers and database volume, then rebuild everything by running:
docker compose down -v && docker compose up --build
Why do we need the -v flag?
The -v flag removes the PostgreSQL data volume. Without it, the database keeps its old data and skips the init script entirely. Deleting the volume forces a fresh database that runs your init.sql on startup.
- Wait until you see Server running on port 3000 in the terminal output.
- Open a second terminal window and test the POST endpoint by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'
You should see a JSON response with a short_url and short_code field. The short code will be 3587cb7 because that's the first 7 characters of the SHA-256 hash of https://example.com.
- Run the same curl command again with the same URL.
You'll get an error because the code already exists and the same URL produces the same hash. This is the collision problem in action. The code tries to insert a duplicate short_code, which violates the UNIQUE constraint.
Getting a connection error?
- Make sure the first terminal still shows Server running on port 3000. If containers crashed, check the logs for errors.
- Confirm you're running the curl command in a separate terminal window from the one running Docker.
- If you see a database error about the urls table not existing, the init script didn't run. Stop containers, run docker compose down -v to delete the volume, then docker compose up --build again.
Why isn't my POST /shorten endpoint returning a short code?
Build the GET /:code redirect endpoint
A short URL is useless unless visiting it takes you to the original destination. This endpoint looks up the short code in PostgreSQL and responds with a 302 redirect.
- In index.js, find the closing }); of the app.post('/shorten') block you just added. Add the following endpoint directly below it:
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
// Look up the short code in PostgreSQL
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
// 302 (temporary) redirect so the browser always comes back to us
res.redirect(302, result.rows[0].original_url);
} catch (error) {
console.error('[ERROR] Failed to redirect:', error.message);
res.status(500).json({ error: 'Failed to redirect' });
}
});
Why 302 instead of 301?
A 301 (permanent) redirect tells the browser to cache the destination. Next time the user clicks the short link, the browser skips your server entirely and goes straight to the original URL.
A 302 (temporary) redirect means the browser always comes back to your server first. This is essential for analytics. If you want to count clicks (which you will in the Secret Mission), every visit must hit your server.
- Save index.js.
- Restart the app container to pick up your code changes by running this in your second terminal:
docker compose restart app
Before you test the redirect, think about this: what do you expect to happen when you visit your short code URL in the terminal? Will you see the HTML of the destination page, or something else?
- First, shorten a fresh URL (since the database was rebuilt earlier) by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'
- Now test the redirect. Replace the code below with the short_code value from your response above:
curl -v http://localhost:3000/[[SHORT_CODE="your short code from the POST response"]]
The -v flag shows the full HTTP response headers. You should see a 302 Found status and a Location: https://example.com header. That's your redirect working.
- Test the 404 path by requesting a code that doesn't exist:
curl http://localhost:3000/doesnotexist
You should see {"error":"Short URL not found"} with a 404 status.
Redirect not working?
- If you get the health check JSON instead of a redirect, make sure the app.get('/:code') route is placed AFTER the app.get('/') route in your file.
- If you get a 404 for a code you just created, make sure you ran docker compose restart app after saving your changes. Without a restart, the container runs the old code.
- Remember that restarting with docker compose restart app does NOT reset the database. Your previously shortened URLs still exist.
Why isn't my redirect endpoint returning a 302?
✔️ Awesome, I've got everything!
Great. Double check you've saved both init.sql and index.js.
ⓧ I'd like to double check the full code
Here's the complete init.sql:
CREATE TABLE IF NOT EXISTS urls (
id SERIAL PRIMARY KEY,
short_code VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_short_code ON urls(short_code);
Here's the complete index.js. Scroll through and confirm each section matches yours:
The file has these sections in order: (1) imports and setup, (2) health check route, (3) POST /shorten route, (4) GET /:code route, (5) server listen. Scroll up and confirm each code block you added matches.
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const redis = new Redis(process.env.REDIS_URL);
app.get('/', (req, res) => {
res.json({ status: 'ok', message: 'URL Shortener is running' });
});
app.post('/shorten', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
try {
let shortCode = crypto.createHash('sha256').update(url).digest('hex').slice(0, 7);
let counter = 0;
while (true) {
const existing = await pool.query('SELECT id FROM urls WHERE short_code = $1', [shortCode]);
if (existing.rows.length === 0) break;
counter++;
shortCode = crypto.createHash('sha256').update(url + counter).digest('hex').slice(0, 7);
}
await pool.query('INSERT INTO urls (short_code, original_url) VALUES ($1, $2)', [shortCode, url]);
res.json({ short_url: `http://localhost:3000/${shortCode}`, short_code: shortCode });
} catch (error) {
console.error('[ERROR] Failed to shorten URL:', error.message);
res.status(500).json({ error: 'Failed to shorten URL' });
}
});
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
res.redirect(302, result.rows[0].original_url);
} catch (error) {
console.error('[ERROR] Failed to redirect:', error.message);
res.status(500).json({ error: 'Failed to redirect' });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your URL shortener can now create short codes and redirect visitors. But every single redirect hits PostgreSQL directly. In the next step, you'll see exactly why that's a problem at scale.
Feel the Pain (No Cache, Direct DB Reads)
Your URL shortener works. You can shorten a link and get redirected back to the original. But there is a hidden problem lurking beneath the surface.
Every single redirect goes directly to PostgreSQL. In a real system where reads outnumber writes 100-to-1, this architecture would collapse under load. Before you fix anything, you need to see the problem firsthand.
In this step, get ready to:
- Add timing logs to measure database query duration on every redirect.
- Run a test script that exposes the constant-DB-hit pattern.
- Document the scale problem in code comments for interview preparation.
Add timing logs to the redirect endpoint
Right now your GET /:code endpoint queries PostgreSQL and redirects, but you have no visibility into how long that database round-trip takes. Adding timing logs makes the cost of every redirect visible in your terminal.
- In index.js, find the GET /:code route handler. Locate this line:
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
res.redirect(302, result.rows[0].original_url);
- Replace the entire GET /:code route handler with this updated version that adds timing:
app.get('/:code', async (req, res) => {
const { code } = req.params;
const start = Date.now();
try {
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
const duration = Date.now() - start;
console.log(`[REDIRECT] ${code} -> DB query took ${duration}ms`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
res.redirect(302, result.rows[0].original_url);
} catch (error) {
console.error('[ERROR] Failed to redirect:', error.message);
res.status(500).json({ error: 'Failed to redirect' });
}
});
What does this code do?
const start = Date.now() captures a timestamp right before the database query. After the query returns, Date.now() - start calculates how many milliseconds the round-trip took.
The console.log prints the short code and duration to your Docker logs, so you can see the cost of every single redirect in real time.
- Save index.js.
- Restart the app container so it picks up the changes by running:
docker compose restart app
- Test the timing logs by shortening a URL and then redirecting to it. Run this to shorten:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/test-timing"}'
- Copy the short_code from the response, then redirect to it by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/YOUR_SHORT_CODE
- Check the Docker logs by running:
docker compose logs app --tail 5
You should see a line like [REDIRECT] abc1234 -> DB query took 5ms in the output. The exact number will vary, but it proves every redirect is hitting PostgreSQL.
Not seeing the log line?
- Make sure you saved index.js before restarting. The volume mount reflects your local files, but the Node process needs a restart to load them.
- If you see Cannot find module, check for a typo in the file. Run docker compose logs app to see the full error.
- help me debug my redirect timing logs
Create and run the test script
A single curl proves the logs work. But to really see the pattern, you need a script that hits the same URLs repeatedly so you can compare response times across multiple redirects.
- Create a new file called test.js in your url-shortener folder. Add this HTTP helper function that wraps Node's built-in http module:
const http = require('http');
const BASE_URL = 'http://localhost:3000';
// Helper to make HTTP requests without external dependencies
function request(method, path, body) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
resolve({ status: res.statusCode, headers: res.headers, body: data });
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
What does this code do?
The request function is a lightweight HTTP client using Node's built-in http module. It returns a Promise with the status code, headers, and response body. This avoids needing to install a third-party HTTP library just for testing.
- Below the request function, add the test runner that shortens 3 URLs and then redirects to each one 5 times:
async function run() {
console.log('--- Shortening URLs ---');
const urls = [
'https://example.com/very-long-url-one',
'https://example.com/very-long-url-two',
'https://example.com/very-long-url-three',
];
const codes = [];
for (const url of urls) {
const res = await request('POST', '/shorten', { url });
const parsed = JSON.parse(res.body);
codes.push(parsed.short_code);
console.log(` ${url} -> ${parsed.short_code}`);
}
console.log('\n--- Redirecting (observe DB hit every time) ---');
for (const code of codes) {
for (let i = 0; i < 5; i++) {
const start = Date.now();
const res = await request('GET', `/${code}`);
const duration = Date.now() - start;
console.log(` GET /${code} -> ${res.status} (${duration}ms)`);
}
}
console.log('\n--- Done! Every redirect hit the DB ---');
}
run().catch(console.error);
What does this code do?
The run() function first shortens 3 different URLs, collecting their short codes. Then it redirects to each code 5 times in a row, measuring and printing the response time for each request.
If caching existed, you would expect the 2nd through 5th requests to be dramatically faster. Without caching, every request should take roughly the same time because each one makes a fresh database query.
- Save test.js.
Before you run the test, think about this: you are redirecting to the same URL 5 times in a row. Do you think the response time will decrease on repeated requests, or stay the same?
- Run the test script by executing:
node test.js
Every single redirect takes roughly the same time (5-15ms), regardless of whether it is the 1st or 5th request to the same URL. There is no speedup on repeated requests.
Why don't repeated requests get faster?
Without a cache, every redirect makes a full round-trip to PostgreSQL. The database does not "remember" that you just asked for the same short code 4 requests ago. Each query is independent, each takes the same network hop and disk read.
This is the exact pattern interviewers want you to identify. The response times are flat because there is no caching layer absorbing repeated reads.
- Check the server-side logs to see the DB query duration for each redirect by running:
docker compose logs app --tail 20
You should see 15 lines of [REDIRECT] logs (3 URLs times 5 redirects each), all showing similar query durations. No request is faster than any other.
Seeing connection errors in the test output?
- Make sure your containers are running. Run docker compose ps and confirm all three services show as "Up".
- If the app container crashed, run docker compose logs app to check for syntax errors in index.js.
- help me fix my test script connection errors
✔️ Awesome, I've got everything!
Great. Make sure you have saved test.js and confirmed the flat response times in your terminal output.
ⓧ I'd like to double check the full code
Scroll up and confirm your test.js has both sections in order:
- The request helper function (imports, BASE_URL constant, and the Promise-based HTTP wrapper).
- The run() function (shortens 3 URLs, redirects 5 times each, prints timing) followed by run().catch(console.error).
Document the scale problem in your code
You have seen the flat response times. Now make this insight permanent by adding comments that explain exactly why this architecture fails at scale. These comments double as interview talking points you can reference later.
- In index.js, find the GET /:code handler. Locate this line inside the try block:
try {
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
- Add these 4 comment lines between try { and the pool.query call, so the block now looks like this:
try {
// No cache: every redirect hits PostgreSQL directly
// At 100:1 read-to-write ratio with 1000 writes/sec = 100,000 reads/sec
// PostgreSQL handles ~10,000-30,000 simple reads/sec per instance
// This architecture would melt the DB at scale
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
What do these numbers mean for interviews?
URL shorteners are read-heavy systems. The typical ratio is 100 reads for every 1 write. If your service handles 1,000 new URLs per second, you face 100,000 redirect lookups per second.
A single PostgreSQL instance handles roughly 10,000-30,000 simple reads per second. At 100,000 reads/sec, your database would need 3-10 replicas just for the redirect path. A caching layer (which you will add next) absorbs 90%+ of those reads and brings latency from milliseconds to sub-millisecond.
- Save index.js.
✔️ Awesome, I've got everything!
Great. Confirm your index.js has the timing logs and the scale comments in the GET /:code handler.
ⓧ I'd like to double check the full code
Your GET /:code handler should now look like this:
app.get('/:code', async (req, res) => {
const { code } = req.params;
const start = Date.now();
try {
// No cache: every redirect hits PostgreSQL directly
// At 100:1 read-to-write ratio with 1000 writes/sec = 100,000 reads/sec
// PostgreSQL handles ~10,000-30,000 simple reads/sec per instance
// This architecture would melt the DB at scale
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
const duration = Date.now() - start;
console.log(`[REDIRECT] ${code} -> DB query took ${duration}ms`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
res.redirect(302, result.rows[0].original_url);
} catch (error) {
console.error('[ERROR] Failed to redirect:', error.message);
res.status(500).json({ error: 'Failed to redirect' });
}
});
You have proven the problem. Every redirect hits PostgreSQL regardless of how many times you have requested the same URL. Next up, you will fix this with Redis and the cache-aside pattern, turning those 5-15ms reads into sub-millisecond cache hits.
Add Redis Caching (Cache-Aside Pattern)
In the last step, you saw the problem firsthand: every single redirect hits PostgreSQL directly.
The fix for read-heavy systems is caching. The cache-aside pattern means your app checks Redis first. If the short code is there, redirect immediately. If not, query PostgreSQL, store the result in Redis for next time, then redirect. This is the number one interview concept for URL shorteners.
In this step, get ready to:
- Implement the cache-aside pattern on the redirect endpoint.
- Add write-through caching to the shorten endpoint.
- Observe Cache HIT vs Cache MISS behavior with measurable timing differences.
Implement cache-aside on the redirect endpoint
Cache-aside works in three stages: check the cache, fall back to the database on a miss, then populate the cache for next time. Your Redis instance is already running in Docker. You just need to wire it into the redirect logic.
- In index.js, find the entire app.get('/:code', ...) route handler.
- Replace it with this cache-aside version:
app.get('/:code', async (req, res) => {
const { code } = req.params;
const start = Date.now();
try {
// Cache-aside pattern: check Redis first
const cached = await redis.get(`url:${code}`);
if (cached) {
const duration = Date.now() - start;
console.log(`[REDIRECT] ${code} -> Cache HIT (${duration}ms)`);
return res.redirect(302, cached);
}
// Cache miss: query PostgreSQL
const result = await pool.query('SELECT original_url FROM urls WHERE short_code = $1', [code]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Short URL not found' });
const originalUrl = result.rows[0].original_url;
// Populate cache for next time (24-hour TTL)
await redis.set(`url:${code}`, originalUrl, 'EX', 86400);
const duration = Date.now() - start;
console.log(`[REDIRECT] ${code} -> Cache MISS (${duration}ms)`);
res.redirect(302, originalUrl);
} catch (error) {
console.error('[ERROR] Failed to redirect:', error.message);
res.status(500).json({ error: 'Failed to redirect' });
}
});
What does this code do?
- redis.get(`url:${code}`) checks Redis for the short code. If it's there, the redirect happens immediately without touching PostgreSQL.
- On a cache miss, the code queries PostgreSQL as before, then stores the result in Redis with 'EX', 86400 (a 24-hour TTL) so subsequent requests skip the database.
- The logs print "Cache HIT" or "Cache MISS" with timing so you can see the performance difference.
Why a 24-hour TTL?
TTL (Time To Live) controls how long cached data stays valid. A 24-hour window balances memory usage against hit rate. URL mappings rarely change, so a long TTL is safe. In production you might go longer or even skip expiry entirely for immutable mappings.
- Save index.js.
- Restart the app container to pick up the changes by running:
docker compose restart app
- Shorten a test URL by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/cache-test"}'
- Copy the short_code value from the JSON response.
- Redirect to it twice by running this command two times (replace the code with your value):
curl -w "\nTotal time: %{time_total}s\n" -o /dev/null -s http://localhost:3000/[[SHORT_CODE="your short code from above"]]
Check the Docker logs (the terminal running docker compose). The first redirect prints "Cache MISS" because Redis was empty. The second prints "Cache HIT" because the first request populated the cache.
Still seeing DB queries on every redirect?
- Make sure you replaced the entire app.get('/:code', ...) handler, not just parts of it. The old comments about "No cache" should be gone.
- Confirm you ran docker compose restart app after saving. The volume mount shares your files, but Node.js needs a restart to load them.
- Check that the Redis container is running with docker compose ps. All three containers should show "Up".
Help me debug why my Redis cache-aside pattern isn't working
Add write-through caching to the shorten endpoint
Right now, the first redirect for a newly shortened URL is always a cache miss. The write-through pattern fixes this by caching the mapping at the moment it's created. That way, the very first redirect is already a cache hit.
- In index.js, scroll to the POST /shorten handler.
- Find this line inside the try block:
await pool.query('INSERT INTO urls (short_code, original_url) VALUES ($1, $2)', [shortCode, url]);
res.json({ short_url: `http://localhost:3000/${shortCode}`, short_code: shortCode });
- Add two lines between the INSERT query and the res.json() call, so the section now looks like this:
await pool.query('INSERT INTO urls (short_code, original_url) VALUES ($1, $2)', [shortCode, url]);
// Write-through: cache immediately so first redirect is a cache hit
await redis.set(`url:${shortCode}`, url, 'EX', 86400);
res.json({ short_url: `http://localhost:3000/${shortCode}`, short_code: shortCode });
What does write-through mean?
Write-through means every write to the database also writes to the cache in the same operation. The cache is always warm for data you just created. Combined with cache-aside on reads, you get zero cache misses for newly shortened URLs.
- Save index.js.
Run the test script and observe the full pattern
Now you have both patterns in place: write-through on create and cache-aside on redirect. Time to see them work together at scale.
- In test.js, find this line in the redirect loop:
for (let i = 0; i < 5; i++) {
- Change 5 to 3 so the line reads:
for (let i = 0; i < 3; i++) {
- Also update the two console.log messages in the run() function. Change '--- Redirecting (observe DB hit every time) ---' to:
console.log('\n--- Redirecting (observe cache behavior) ---');
- Change '--- Done! Every redirect hit the DB ---' to:
console.log('\n--- Done! Check server logs for Cache HIT/MISS ---');
- Save test.js.
- Restart the app container to load your write-through changes by running:
docker compose restart app
Before you run the test, think about what you expect to see. The test shortens 3 URLs (triggering write-through), then redirects to each one 3 times. Will the first redirect be a Cache HIT or a Cache MISS?
- Run the test script by running:
docker compose exec app node test.js
Check the server logs in your Docker terminal. Every redirect shows "Cache HIT" because write-through pre-warmed the cache when each URL was shortened. The response times are dramatically lower than the all-DB approach from the previous step.
Why is every redirect a Cache HIT?
The write-through line in POST /shorten cached each URL-to-code mapping the moment it was created. By the time the test script redirects, Redis already has the data. Without write-through, the first redirect for each URL would be a Cache MISS (hitting PostgreSQL), and only subsequent redirects would be HITs.
This is a key interview talking point. Write-through eliminates cold-start latency for newly created resources at the cost of slightly slower writes.
Seeing Cache MISS on every request?
- Make sure you added the redis.set line inside POST /shorten and saved the file before restarting.
- Confirm Redis is reachable by running docker compose exec redis redis-cli ping. You should see PONG.
- If you previously ran the test before restarting, the old URLs are already in PostgreSQL but not in Redis. Run docker compose down -v && docker compose up --build to start fresh.
Help me debug why Redis caching isn't working with my URL shortener
✔️ Awesome, I've got everything!
Great. Double check you have saved both index.js and test.js.
ⓧ I'd like to double check the full code
Your index.js is longer than a single glance, so confirm these sections are all present in order:
- The require statements at the top (express, pg, ioredis, crypto).
- The Pool and Redis client setup.
- The GET / health check route.
- The POST /shorten route with the hash-based logic AND the new redis.set write-through line before res.json().
- The GET /:code route with cache-aside (check Redis, log HIT/MISS, populate on miss).
- The app.listen call at the bottom.
For test.js, confirm the redirect loop uses i < 3 and the messages say "observe cache behavior" and "Check server logs for Cache HIT/MISS".
Your shortener is now backed by Redis caching. Reads that used to hit PostgreSQL every time now resolve from memory in under a millisecond. Next up, you'll fix the other flaw in your design: the hash-based short codes that are prone to collisions at scale.
Fix the ID Strategy (Counter-Based Base62)
Your shortener works, caching is in place, and redirects are fast. But there's a fundamental flaw in the hash-based ID generation that any interviewer will probe: collision probability grows with scale.
At around 68 million URLs, a 7-character truncated SHA-256 hash has roughly a 1% collision rate. Counter-based Base62 encoding eliminates collisions entirely by deriving codes from auto-increment IDs. Each URL gets the next serial number, encoded into a short alphanumeric string.
In this step, get ready to:
- Create a Base62 encoding utility module.
- Replace hash-based generation with counter-based Base62 encoding.
- Verify collision-free short code generation.
Create the Base62 encoding module
Base62 encoding maps a number to a string using 62 characters: digits 0-9, lowercase a-z, and uppercase A-Z. With 7 characters, Base62 supports approximately 3.5 trillion unique codes.
- Create a new file called base62.js in your url-shortener folder and add this code:
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(num) {
if (num === 0) return CHARSET[0];
let result = '';
while (num > 0) {
result = CHARSET[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function decode(str) {
let result = 0;
for (const char of str) {
result = result * 62 + CHARSET.indexOf(char);
}
return result;
}
module.exports = { encode, decode };
What does this code do?
- CHARSET defines the 62 characters used for encoding, in a fixed order so each character maps to a specific numeric value.
- encode(num) converts a number (like a database ID) into a short Base62 string by repeatedly dividing by 62 and mapping remainders to characters.
- decode(str) reverses the process, converting a Base62 string back into the original number.
- Save base62.js.
- Confirm the file exists by running:
ls base62.js
You should see the filename printed back to you.
File not found?
Make sure you created base62.js in the same folder as index.js (the url-shortener folder on your Desktop). Run ls to see all files and confirm you're in the right directory.
Help me find where my base62.js file went
Switch POST /shorten to counter-based encoding
The current approach hashes the URL, checks for collisions, and loops until it finds a unique code. The counter-based approach is simpler: insert the URL, grab the auto-generated ID, and encode it as Base62.
- In index.js, find this line near the top:
const crypto = require('crypto');
- Replace it with this line, which imports the Base62 encoder you just created:
const { encode } = require('./base62');
- Now find the entire app.post('/shorten', ...) handler and replace it with this new version:
app.post('/shorten', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
try {
// Counter-based Base62: insert first to get auto-generated serial ID, then encode
// Trade-off: collision-free by construction, but same URL shortened twice gets different codes
// Hash-based: same URL always gets same code (deduplication), but collision risk at scale
const result = await pool.query(
'INSERT INTO urls (short_code, original_url) VALUES ($1, $2) RETURNING id',
['placeholder', url]
);
const id = result.rows[0].id;
const shortCode = encode(id);
await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [shortCode, id]);
await redis.set(`url:${shortCode}`, url, 'EX', 86400);
const shortUrl = `http://localhost:3000/${shortCode}`;
console.log(`[SHORTEN] ${url} -> ${shortCode}`);
res.json({ short_url: shortUrl, short_code: shortCode });
} catch (error) {
console.error('[ERROR] Failed to shorten URL:', error.message);
res.status(500).json({ error: 'Failed to shorten URL' });
}
});
What's the key difference here?
The old code hashed the URL and looped until finding a unique code. This new code inserts with a temporary 'placeholder' value to get PostgreSQL's auto-generated serial ID, then encodes that ID as Base62.
The comments capture the interview trade-off: counter-based gives zero collisions (each ID is unique by construction), but the same URL shortened twice produces two different codes. Hash-based gives the same code for the same URL (deduplication), but has collision risk at scale. Interviewers want to hear you articulate when each matters.
- Save index.js.
Syntax errors after editing?
Make sure you deleted the entire old app.post('/shorten', ...) handler including its closing }); before pasting the new one. Duplicate route handlers or mismatched braces will crash the server on restart.
Help me fix a syntax error in my index.js after replacing the shorten handler
Rebuild and verify collision-free codes
You need to wipe the old database data (which contains hash-based codes) and rebuild the containers so the new code takes effect.
- Stop the running containers and delete the database volume by running:
docker compose down -v
- Rebuild and start fresh by running:
docker compose up --build
Wait until you see Server running on port 3000 in the terminal output.
Before you test, think about this: if you shorten the same URL twice with this new counter-based approach, do you expect the same short code or a different one? Why?
- Open a new terminal tab and shorten three different URLs by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/first"}'
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/second"}'
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/third"}'
You should see sequential Base62 codes like 1, 2, 3 (or b, c, d depending on your database's auto-increment starting point). The codes are short, sequential, and generated with zero collision logic.
- Now shorten the same URL twice to confirm the non-deterministic behaviour:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/duplicate-test"}'
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com/duplicate-test"}'
You get two different short codes for the same URL. This is the trade-off: counter-based encoding is collision-free, but it does not deduplicate. Hash-based gives the same code for the same URL, but risks collisions at scale.
Why does this matter in interviews?
Interviewers want to hear you articulate this trade-off. Hash-based is good when you want the same short code for duplicate URLs (saves storage). Counter-based is good when you want guaranteed uniqueness at any scale (simpler, no collision loops).
Real services like bit.ly use counter-based approaches because at billions of URLs, collision-free generation is more valuable than deduplication. You can always add a lookup index on original_url later if deduplication matters.
Getting a 500 error or 'placeholder' stuck in the database?
Make sure your base62.js file is in the same directory as index.js. The require('./base62') path is relative to the file doing the import.
If you see an error about a unique constraint on short_code, the 'placeholder' value may be conflicting. Run docker compose down -v and docker compose up --build to wipe the database and start fresh.
Help me debug my counter-based URL shortener
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Here is the complete base62.js:
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(num) {
if (num === 0) return CHARSET[0];
let result = '';
while (num > 0) {
result = CHARSET[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
function decode(str) {
let result = 0;
for (const char of str) {
result = result * 62 + CHARSET.indexOf(char);
}
return result;
}
module.exports = { encode, decode };
For index.js, scroll up and confirm these pieces are present in order:
- Line 4: const { encode } = require('./base62'); (no more crypto import).
- The POST /shorten handler uses 'placeholder' insert, then encode(id), then UPDATE.
- The three trade-off comments sit inside the try block before the query.
- The GET /:code handler is unchanged (cache-aside pattern with HIT/MISS logs).
Your URL shortener now generates collision-free codes by construction. No more hashing loops, no collision checks, and it scales to 3.5 trillion URLs with 7-character codes. You can confidently explain both strategies and their trade-offs in an interview setting.
Secret mission
Add Click Analytics
Real URL shorteners track every click. Can you add a clicks counter and a stats endpoint that shows how many times each short URL has been visited? This teaches you how to handle analytics without blocking the redirect path.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing costs.
Resources you used:
- Docker containers: app, postgres, redis (all running locally).
- Docker volume: pgdata (stores PostgreSQL data between restarts).
- Local project files in your url-shortener folder on the Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your URL shortener.
- Your Docker containers remain running on port 3000.
- PostgreSQL keeps your shortened URLs in the pgdata volume.
- Redis continues caching redirects in memory.
Pause - I'll come back to this later
Shut down running containers to free up memory, but keep your data and files intact for later.
- Stop all containers by running this command in your terminal from the url-shortener folder:
docker compose stop
- Your PostgreSQL data volume is preserved. When you're ready to resume, run docker compose up to restart everything.
Delete - I don't want to use this again
Remove all Docker containers, volumes, and project files completely.
- Stop and remove all containers and volumes by running this command from the url-shortener folder:
docker compose down -v
- This removes the app, postgres, and redis containers along with the pgdata volume. All shortened URLs are gone.
- Delete the project folder. On macOS or Linux:
cd ~/Desktop
rm -rf url-shortener
- On Windows:
cd ~\Desktop
rmdir /s /q url-shortener
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see url-shortener listed.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade URL shortener from scratch, complete with PostgreSQL persistence, Redis caching, and Base62 encoding.
You've learned how to:
- Build a URL shortener API with Express, PostgreSQL, and Redis, comparing hash-based (collision-prone) and counter-based Base62 (collision-free) ID generation strategies.
- Apply the cache-aside pattern with Redis and measure the performance difference between direct database reads and sub-millisecond cache hits on a read-heavy system.
- Articulate key system design trade-offs including 302 vs 301 redirects for analytics, collision handling, and why caching is essential at a 100:1 read-to-write ratio.
- Secret Mission: Added click analytics tracking with an async increment and a stats endpoint that reports total clicks per short URL.
Ready to quiz yourself?