Build a URL Shortener: System Design
Build a production-style URL shortener with caching, async analytics, and Base62 encoding.
Introduction
30 Second Summary
Every time you click a shortened link, a system has milliseconds to look up the original URL, redirect your browser, and record that click. Behind a simple seven-character code is an architecture handling read-heavy traffic, layered caching, and real-time analytics.
In this project, you will build a production-style URL shortener backend from scratch. You'll implement Base62 encoding for collision-free short codes, Redis caching for fast redirects, and a background analytics worker that tracks clicks asynchronously.
What You'll Build
A Node.js/Express API with three distinct paths: a write path that shortens URLs, a read path that redirects via cached short codes, and an analytics path that tracks clicks asynchronously through Redis.
By the end of this project, you'll have:
- A working URL shortener API with Base62 encoding, PostgreSQL persistence, and Redis caching.
- An async analytics pipeline that buffers click counts in Redis and flushes them to PostgreSQL via a background worker.
- A Docker Compose setup running PostgreSQL and Redis with zero native database installs.
- Secret Mission: Add URL expiration with TTL support and return 410 Gone for expired links.
Are there any prerequisites?
This project assumes you're comfortable with backend development concepts like APIs, databases, and HTTP. You don't need prior experience with Redis or Docker.
Set Up the Project and Infrastructure
Before you can build the URL shortener itself, you need a project folder, the right packages, and two pieces of infrastructure running locally: a PostgreSQL database to store URL mappings and a Redis cache to serve fast lookups.
In this step, you will scaffold the Node.js project, pin your dependencies to exact versions, and spin up both services with Docker Compose. By the end, your local infrastructure will be healthy and ready for code.
In this step, get ready to:
- Initialize the project directory and install pinned dependencies.
- Write a Docker Compose file that defines PostgreSQL and Redis services.
- Start both containers and verify connectivity from Node.js.
Initialize the Node.js project
Every Node.js project starts with a package.json file that declares its name, entry point, scripts, and dependencies. You will create the project folder, scaffold a starter package.json, then customize it with the exact dependency versions this project uses.
- In VS Code, open the integrated terminal by pressing Ctrl+` (the backtick key above Tab).
- 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
- Scaffold a starter package.json by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all defaults without asking questions. It creates a basic package.json with the folder name as the project name and placeholder values for everything else.
The generated package.json needs customization. You will replace its contents with a version that declares your run scripts and pins the three packages this project uses to exact versions.
- Open the url-shortener folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the url-shortener folder on your Desktop, then click Open.
- In the Explorer sidebar, click package.json to open it.
- Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
{
"name": "url-shortener",
"version": "1.0.0",
"description": "A URL shortener demonstrating system design patterns",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate": "node src/migrate.js",
"worker": "node src/worker.js"
},
"dependencies": {
"express": "4.22.2",
"ioredis": "5.11.0",
"pg": "8.21.0"
}
}
What does this file do?
This package.json declares three npm scripts you will use throughout the project:
- start runs the Express API server.
- migrate creates the database tables.
- worker launches the background analytics flush job.
The dependencies are pinned to exact versions (no ^ prefix) so every learner gets identical behavior regardless of when they run npm install.
- Save package.json (Cmd+S on macOS or Ctrl+S on Windows).
- Install the declared dependencies by running this in the integrated terminal:
npm install
- You should see output confirming three packages were added. A node_modules folder appears in the Explorer sidebar.
Don't see node_modules?
Make sure you saved package.json before running npm install. If npm reports errors about missing fields, double-check that your file matches the code block above exactly.
Help me debug my npm install output.
✔️ Awesome, I've got everything!
Great. Double check you have saved package.json and that node_modules appears in your sidebar.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "url-shortener",
"version": "1.0.0",
"description": "A URL shortener demonstrating system design patterns",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate": "node src/migrate.js",
"worker": "node src/worker.js"
},
"dependencies": {
"express": "4.22.2",
"ioredis": "5.11.0",
"pg": "8.21.0"
}
}
Write the Docker Compose file
Your URL shortener needs two backing services: PostgreSQL for durable storage and Redis for caching and click counters. Instead of installing them natively, you will define both in a single docker-compose.yml file that Docker can spin up in seconds.
- In the Explorer sidebar, hover over the project name and click the New File icon.
- Name the file docker-compose.yml and press Enter.
- Paste the following configuration into the new file:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: shortener
POSTGRES_PASSWORD: shortener
POSTGRES_DB: url_shortener
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shortener -d url_shortener"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
What does this file do?
This Compose file defines two services that Docker will manage for you:
- postgres pulls the official PostgreSQL 16 image, creates a database called url_shortener, and exposes it on port 5432.
- redis pulls the official Redis 7 image and exposes it on port 6379.
- Both services include a healthcheck so Docker can report whether each container is actually ready to accept connections, not just running.
- Save docker-compose.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) and that each nested key is indented by exactly two spaces relative to its parent. If Docker complains about the file later, check for stray tabs or misaligned keys.
Help me fix my docker-compose.yml formatting.
✔️ Awesome, I've got everything!
Great. Make sure you have saved docker-compose.yml before moving on.
ⓧ I'd like to double check the full code
Your docker-compose.yml should look exactly like this:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: shortener
POSTGRES_PASSWORD: shortener
POSTGRES_DB: url_shortener
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shortener -d url_shortener"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
Start infrastructure and verify
With both services defined, you can start them in the background and confirm they are healthy before writing any application code.
- Start both containers in detached mode by running:
docker compose up -d
What does this command do?
The -d flag runs the containers in the background (detached mode). Docker pulls the images on first run, which may take a minute. Subsequent starts are instant because the images are cached locally.
- Check that both containers are healthy by running:
docker compose ps
- You should see both postgres and redis listed with a status of healthy. If you see starting, wait a few seconds and run the command again.
Containers not showing as healthy?
If a container shows unhealthy or exited, check its logs with docker compose logs postgres or docker compose logs redis. The most common cause is another process already using port 5432 or 6379.
Help me debug my Docker containers.
The containers are running, but can your Node.js code actually reach them? A quick connectivity script confirms the packages you installed can talk to both services.
- Test the Redis connection by running:
node -e "const Redis = require('ioredis'); const r = new Redis(); r.ping().then(v => { console.log('Redis:', v); process.exit(0); })"
- You should see Redis: PONG printed in the terminal.
- Test the PostgreSQL connection by running:
node -e "const {Pool} = require('pg'); const p = new Pool({user:'shortener',password:'shortener',database:'url_shortener'}); p.query('SELECT NOW()').then(r => { console.log('PostgreSQL:', r.rows[0].now); process.exit(0); })"
- You should see PostgreSQL: followed by the current timestamp. This confirms your Node.js packages can connect to both Docker services.
Seeing a connection refused error?
If Node.js cannot connect, make sure the containers are running and healthy (docker compose ps). Give the PostgreSQL container up to 15 seconds after showing healthy before retrying the connectivity test.
Also confirm that npm install completed without errors. If you see Cannot find module, your node_modules folder may be incomplete. Re-run npm install from inside the url-shortener folder.
Help me troubleshoot my Node.js connection to Docker.
Your infrastructure is live and your Node.js project can talk to both services. Next up, you will design the database schema and build the Base62 encoder that turns numeric IDs into short, shareable codes.
Design the Database Schema and Base62 Encoder
Your infrastructure is up and running. Now you need to design the data layer that powers the entire shortener.
A URL shortener needs two core pieces: a database table to store mappings between short codes and long URLs, and an encoding scheme to generate those short codes. In this step, you'll build both from scratch using PostgreSQL for persistence and Base62 encoding for collision-free ID generation.
In this step, get ready to:
- Write the configuration, database, and Redis connection modules.
- Create a migration script that builds the URL storage schema.
- Implement a Base62 encoder that converts numeric IDs into short codes.
Create the PostgreSQL schema
Before you can write the migration, you need a few supporting modules. These handle configuration, database connection pooling, and Redis connectivity.
- Create the src/ directory by running this command:
mkdir src
- Confirm the directory exists by running:
ls
You should see src listed alongside docker-compose.yml, package.json, and node_modules.
- Create a new file called src/config.js and add the following code:
module.exports = {
port: process.env.PORT || 3000,
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
postgres: {
host: process.env.PG_HOST || 'localhost',
port: parseInt(process.env.PG_PORT || '5432'),
user: process.env.PG_USER || 'shortener',
password: process.env.PG_PASSWORD || 'shortener',
database: process.env.PG_DATABASE || 'url_shortener',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
cacheTtl: 86400, // 24 hours in seconds
flushInterval: 30000, // 30 seconds in milliseconds
};
What does this code do?
This module centralizes all configuration values for the project. Each setting reads from an environment variable first, with a local-development default as a fallback.
- The postgres object contains connection details matching the credentials in your docker-compose.yml.
- The redis object points to your local Redis container on port 6379.
- The cacheTtl and flushInterval values control caching and analytics behavior you'll build in later steps.
- Save src/config.js.
- Create a new file called src/db.js and add the following code:
const { Pool } = require('pg');
const config = require('./config');
const pool = new Pool(config.postgres);
module.exports = pool;
What does this code do?
This creates a connection pool for PostgreSQL. A pool manages multiple database connections efficiently. Instead of opening and closing a connection for every query, the pool reuses existing connections.
The Pool class from the pg library handles this automatically. You pass in the config and it manages the rest.
Every file in the project that needs the database will import this same pool instance.
- Save src/db.js.
- Create a new file called src/redis.js and add the following code:
const Redis = require('ioredis');
const config = require('./config');
const redis = new Redis({
host: config.redis.host,
port: config.redis.port,
});
module.exports = redis;
What does this code do?
This creates a single Redis client instance using ioredis. It connects to the Redis container running on port 6379.
Like the database pool, every file that needs Redis will import this shared instance.
- Save src/redis.js.
Now you have the infrastructure modules in place. Next, you'll write the migration that creates the actual database table.
- Create a new file called src/migrate.js and add the following code:
const pool = require('./db');
async function migrate() {
const createTableQuery = `
CREATE TABLE IF NOT EXISTS urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
click_count BIGINT DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code);
`;
try {
await pool.query(createTableQuery);
console.log('Migration complete: urls table created.');
} catch (err) {
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
await pool.end();
}
}
migrate();
What does this code do?
This migration script creates the urls table that stores every shortened URL in the system.
- The id column is a BIGSERIAL (auto-incrementing 64-bit integer). This is the number you'll feed into Base62 encoding to generate short codes.
- The short_code column is capped at 7 characters with a UNIQUE constraint. No two URLs can share a code.
- The click_count column tracks total clicks. The analytics worker you'll build later flushes counts here.
- The index on short_code makes lookups O(log n) instead of scanning every row.
- Save src/migrate.js.
File not saving?
Make sure you created the src/ directory first. VS Code will show an error if you try to save a file into a folder that does not exist.
Help me troubleshoot file creation issues in my project.
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved in your src/ folder.
ⓧ I'd like to double check the full code
module.exports = {
port: process.env.PORT || 3000,
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
postgres: {
host: process.env.PG_HOST || 'localhost',
port: parseInt(process.env.PG_PORT || '5432'),
user: process.env.PG_USER || 'shortener',
password: process.env.PG_PASSWORD || 'shortener',
database: process.env.PG_DATABASE || 'url_shortener',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
cacheTtl: 86400, // 24 hours in seconds
flushInterval: 30000, // 30 seconds in milliseconds
};
const { Pool } = require('pg');
const config = require('./config');
const pool = new Pool(config.postgres);
module.exports = pool;
const Redis = require('ioredis');
const config = require('./config');
const redis = new Redis({
host: config.redis.host,
port: config.redis.port,
});
module.exports = redis;
const pool = require('./db');
async function migrate() {
const createTableQuery = `
CREATE TABLE IF NOT EXISTS urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
click_count BIGINT DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code);
`;
try {
await pool.query(createTableQuery);
console.log('Migration complete: urls table created.');
} catch (err) {
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
await pool.end();
}
}
migrate();
Implement the Base62 encoder/decoder
URL shorteners need a way to turn a database ID (like 1000) into a compact string (like qi). Base62 encoding does exactly this using the characters a-z, A-Z, and 0-9.
Because each short code maps to a unique auto-increment ID, collisions are impossible by construction. No hash truncation, no retry logic, no duplicate checks needed.
- Create a new file called src/base62.js and add the following code:
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const BASE = ALPHABET.length; // 62
function encode(num) {
if (num === 0) return ALPHABET[0];
let encoded = '';
while (num > 0) {
encoded = ALPHABET[num % BASE] + encoded;
num = Math.floor(num / BASE);
}
return encoded;
}
function decode(str) {
let num = 0;
for (const char of str) {
num = num * BASE + ALPHABET.indexOf(char);
}
return num;
}
module.exports = { encode, decode };
What does this code do?
The encode function converts a number into a Base62 string. It works like converting decimal to any other base. Divide by 62 repeatedly, using the remainder to pick a character from the alphabet.
The decode function reverses the process. It walks through each character, multiplies the running total by 62, and adds the character's position in the alphabet.
With 7 characters of Base62, you can represent approximately 3.5 trillion unique URLs. That is more than enough capacity for any practical system.
- Save src/base62.js.
Why Base62 over hashing?
Hash-based approaches (like truncating MD5 or SHA to 7 characters) have collision risk that grows with scale. At millions of URLs, birthday-paradox collisions become frequent enough to require uniqueness checks on every write.
Base62 on an auto-increment ID is collision-free by construction. The database guarantees unique IDs, and the encoding is a one-to-one mapping. No retries, no collision handling, no extra database queries.
✔️ Awesome, I've got everything!
Great. Make sure src/base62.js is saved.
ⓧ I'd like to double check the full code
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const BASE = ALPHABET.length; // 62
function encode(num) {
if (num === 0) return ALPHABET[0];
let encoded = '';
while (num > 0) {
encoded = ALPHABET[num % BASE] + encoded;
num = Math.floor(num / BASE);
}
return encoded;
}
function decode(str) {
let num = 0;
for (const char of str) {
num = num * BASE + ALPHABET.indexOf(char);
}
return num;
}
module.exports = { encode, decode };
Run the migration and test Base62 encoding
You have all the modules written. Now you'll run the migration against your Docker PostgreSQL instance and verify the Base62 encoder produces correct output.
- Run the migration script by running this command:
npm run migrate
You should see Migration complete: urls table created. printed in your terminal.
Seeing 'Migration failed'?
This usually means your PostgreSQL container is not running or the connection details are wrong.
- Run docker compose ps to check that both containers show a healthy status.
- Verify the credentials in src/config.js match those in docker-compose.yml (user: shortener, password: shortener, database: url_shortener).
Help me debug my PostgreSQL migration connection error.
- Verify the table was created by running this command:
docker compose exec postgres psql -U shortener -d url_shortener -c "\dt urls"
You should see a table listing showing urls with the schema public and type table.
- Test the Base62 encoder by running this command:
node -e "const { encode, decode } = require('./src/base62'); console.log('encode(1):', encode(1)); console.log('encode(1000):', encode(1000)); console.log('decode(qi):', decode('qi'));"
You should see encode(1): b, encode(1000): qi, and decode(qi): 1000 printed. This confirms the encoder and decoder are working as inverse operations.
Seeing 'Cannot find module'?
Make sure you are running the command from the url-shortener project directory (the folder containing package.json). The path ./src/base62 is relative to where you run the command.
Help me fix my module path issue.
Your data layer is locked in. You have a schema that stores URL mappings and an encoder that generates compact, collision-free short codes from sequential IDs. Next up, you'll build the write path that ties these together into a working API endpoint.
Build the Write Path
Your database is ready and your Base62 encoder can generate collision-free short codes from numeric IDs.
Now it's time to build the API that ties everything together. The write path is the journey a URL takes when someone wants to shorten it. Your Express server will accept a long URL, store it in PostgreSQL, generate a short code, and pre-warm the Redis cache so the very first redirect is fast.
In this step, get ready to:
- Create the Express server with a POST /shorten endpoint.
- Add input validation and proper HTTP error codes.
- Warm the Redis cache on every write so redirects start fast.
Create the Express server and POST /shorten endpoint
The Express server is the entrypoint for your URL shortener. It needs to accept JSON request bodies, validate them, insert into PostgreSQL, generate a short code, and respond with the shortened URL.
- Create a new file called src/server.js in your src/ folder.
- Add the imports and Express app setup by pasting this code:
const express = require('express');
const pool = require('./db');
const redis = require('./redis');
const { encode } = require('./base62');
const config = require('./config');
const app = express();
app.use(express.json());
What does this code do?
- You import the pool (PostgreSQL connection), redis client, encode function, and config you created in earlier steps.
- express.json() is middleware that parses incoming JSON request bodies so you can access them via req.body.
- Below the app setup, add the POST /shorten endpoint:
// POST /shorten - Create a short URL
app.post('/shorten', async (req, res) => {
const { long_url } = req.body;
if (!long_url) {
return res.status(400).json({ error: 'long_url is required' });
}
try {
new URL(long_url);
} catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
try {
const result = await pool.query(
'INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id, short_code',
['placeholder', long_url]
);
const id = result.rows[0].id;
const shortCode = encode(Number(id));
await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [shortCode, id]);
// Cache on write
await redis.set(`url:${shortCode}`, long_url, 'EX', config.cacheTtl);
res.status(201).json({
short_url: `${config.baseUrl}/${shortCode}`,
short_code: shortCode,
long_url: long_url,
});
} catch (err) {
console.error('Error creating short URL:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
What does this endpoint do?
This handler implements the full write path in five steps:
- Validates that long_url exists in the request body and is a valid URL format.
- Inserts a row into PostgreSQL with a temporary 'placeholder' short code (you need the auto-generated id first).
- Base62-encodes the returned id into a collision-free short code and updates the row.
- Caches the mapping in Redis so the first redirect is a cache hit.
- Returns a 201 response with the shortened URL.
Add input validation and error handling
Your POST handler already includes two layers of validation. Understanding why each exists helps you design robust APIs.
Why two validation checks?
The first check (if (!long_url)) catches requests with a missing body field entirely. This returns a 400 immediately without touching the database.
The second check (new URL(long_url)) uses Node's built-in URL parser to validate the format. If the string is not a parseable URL, the constructor throws and you catch it to return a 400. This prevents garbage data from reaching PostgreSQL.
By returning 400 (Bad Request) for client errors and 500 (Internal Server Error) for unexpected failures, you give API consumers clear signals about what went wrong. A 201 (Created) on success confirms the resource was stored.
Warm the Redis cache on write
The redis.set line after the database insert is the cache-on-write pattern. Instead of waiting for the first redirect to trigger a cache miss, you proactively store the mapping so that every short URL is instantly available from Redis.
Why cache-aside on write?
URL shorteners are read-heavy. A typical ratio is 100 reads for every 1 write. If you only cache on read (cache miss triggers a DB query), then the very first redirect after creation always hits PostgreSQL.
By caching on write, you guarantee that 100% of redirects start as cache hits from the moment the URL is created. The 'EX', config.cacheTtl sets a 24-hour expiry so stale data eventually evicts itself.
- Now complete src/server.js by adding the stats endpoint, the redirect endpoint, and the server listener below the POST handler:
// GET /:code/stats
app.get('/:code/stats', async (req, res) => {
const { code } = req.params;
try {
const result = await pool.query(
'SELECT long_url, click_count, created_at FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
const row = result.rows[0];
const pendingClicks = await redis.get(`clicks:${code}`);
const totalClicks = Number(row.click_count) + Number(pendingClicks || 0);
res.json({
short_code: code,
long_url: row.long_url,
total_clicks: totalClicks,
created_at: row.created_at,
});
} catch (err) {
console.error('Error fetching stats:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /:code - Redirect
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
let longUrl = await redis.get(`url:${code}`);
if (!longUrl) {
const result = await pool.query(
'SELECT long_url FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
longUrl = result.rows[0].long_url;
await redis.set(`url:${code}`, longUrl, 'EX', config.cacheTtl);
}
redis.incr(`clicks:${code}`);
res.redirect(302, longUrl);
} catch (err) {
console.error('Error redirecting:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(config.port, () => {
console.log(`URL Shortener running on ${config.baseUrl}`);
});
What are these routes for?
These two endpoints complete your server. You will explore them in depth in the next steps.
- GET /:code/stats returns click analytics for a short URL (you will build the analytics worker in a later step).
- GET /:code handles the actual redirect using a cache-aside pattern with Redis.
- app.listen starts the server on port 3000.
- Save src/server.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
const express = require('express');
const pool = require('./db');
const redis = require('./redis');
const { encode } = require('./base62');
const config = require('./config');
const app = express();
app.use(express.json());
// POST /shorten - Create a short URL
app.post('/shorten', async (req, res) => {
const { long_url } = req.body;
if (!long_url) {
return res.status(400).json({ error: 'long_url is required' });
}
try {
new URL(long_url);
} catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
try {
const result = await pool.query(
'INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id, short_code',
['placeholder', long_url]
);
const id = result.rows[0].id;
const shortCode = encode(Number(id));
await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [shortCode, id]);
// Cache on write
await redis.set(`url:${shortCode}`, long_url, 'EX', config.cacheTtl);
res.status(201).json({
short_url: `${config.baseUrl}/${shortCode}`,
short_code: shortCode,
long_url: long_url,
});
} catch (err) {
console.error('Error creating short URL:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /:code/stats
app.get('/:code/stats', async (req, res) => {
const { code } = req.params;
try {
const result = await pool.query(
'SELECT long_url, click_count, created_at FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
const row = result.rows[0];
const pendingClicks = await redis.get(`clicks:${code}`);
const totalClicks = Number(row.click_count) + Number(pendingClicks || 0);
res.json({
short_code: code,
long_url: row.long_url,
total_clicks: totalClicks,
created_at: row.created_at,
});
} catch (err) {
console.error('Error fetching stats:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /:code - Redirect
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
let longUrl = await redis.get(`url:${code}`);
if (!longUrl) {
const result = await pool.query(
'SELECT long_url FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
longUrl = result.rows[0].long_url;
await redis.set(`url:${code}`, longUrl, 'EX', config.cacheTtl);
}
redis.incr(`clicks:${code}`);
res.redirect(302, longUrl);
} catch (err) {
console.error('Error redirecting:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(config.port, () => {
console.log(`URL Shortener running on ${config.baseUrl}`);
});
- Start the server by running this command:
npm start
- You should see this output in your terminal:
URL Shortener running on http://localhost:3000
- Test the POST /shorten endpoint by opening a second terminal and running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"long_url": "https://www.example.com/very-long-page-name"}'
- You should see a 201 response with a JSON body containing short_url, short_code, and long_url fields.
- Verify the cache was populated by checking Redis directly:
docker exec -it $(docker ps -qf "ancestor=redis:7") redis-cli KEYS "url:*"
- You should see at least one key like url:b listed in the output. This confirms the cache-on-write pattern is working.
Server not starting?
- If you see a "Cannot find module" error, make sure your Docker containers are running (docker compose ps should show both healthy) and that you ran npm install in an earlier step.
- If port 3000 is already in use, stop any other process on that port or change the port value in src/config.js.
- If the curl command returns "Connection refused", make sure npm start is still running in the first terminal.
Ask for help debugging your server setup.
Your write path is live. You can now shorten any URL and it immediately lands in both PostgreSQL and the Redis cache. Next up, you will build the read path that resolves short codes back into redirects.
Build the Read Path with Redis Caching
Your write path creates short URLs and caches them immediately. Now it's time to focus on the other side: the read path.
URL shorteners are read-heavy systems. For every URL created, there are roughly 100 redirect requests. Your GET /:code endpoint needs to serve these as fast as possible. The cache-aside pattern in your redirect handler makes this possible by checking Redis before ever touching PostgreSQL.
In this step, get ready to:
- Trace the cache-aside pattern in your redirect endpoint.
- Verify the 302 redirect and async click tracking design.
- Test the full shorten-and-redirect flow end to end.
Trace the cache-aside pattern in your redirect endpoint
The cache-aside pattern means your application checks the cache first, and only falls back to the database on a cache miss. Let's trace how this works in the GET /:code handler you already have in src/server.js.
- In VS Code, open src/server.js and find the GET /:code - Redirect handler near the bottom of the file.
- Read through the handler logic. Here's the section that implements cache-aside:
// Check Redis cache first
let longUrl = await redis.get(`url:${code}`);
if (!longUrl) {
// Cache miss - query PostgreSQL
const result = await pool.query(
'SELECT long_url FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
longUrl = result.rows[0].long_url;
// Populate cache
await redis.set(`url:${code}`, longUrl, 'EX', config.cacheTtl);
}
What does this code do?
This is the cache-aside pattern in action. The handler checks Redis for the key url:{code} first.
- Cache hit: Redis returns the long URL instantly. The handler skips PostgreSQL entirely.
- Cache miss: The handler queries PostgreSQL, gets the long URL, and writes it into Redis with a 24-hour TTL (config.cacheTtl is 86400 seconds). The next request for this code will be a cache hit.
Because the write path already caches on insert, the very first redirect after shortening is always a cache hit. The cache miss path is only for codes where the cache has expired or been evicted.
Understand the 302 redirect and analytics decoupling
After resolving the long URL (from cache or database), the handler does two things: it increments a click counter and redirects the user. The order and method matter.
- Find these two lines at the bottom of the GET /:code handler:
// Increment click counter in Redis (non-blocking, fire-and-forget)
redis.incr(`clicks:${code}`);
// 302 redirect preserves analytics (browser always comes back)
res.redirect(302, longUrl);
Why 302 instead of 301?
HTTP 301 (permanent redirect) lets the browser cache the redirect indefinitely. After the first click, the browser never contacts your server again. You lose all analytics.
HTTP 302 (temporary redirect) forces the browser back to your server on every click. Every single visit passes through your endpoint, which means every click gets counted.
What does fire-and-forget mean here?
Notice that redis.incr(`clicks:${code}`) is called without await. The server fires off the increment to Redis but does not wait for confirmation before sending the redirect response. This keeps the redirect path as fast as possible.
Redis INCR is atomic and O(1). It increments the counter without any locking or race conditions, even under concurrent requests.
Test the full shorten-and-redirect flow
Time to prove the entire system works end to end. You'll create a short URL, hit the redirect, and inspect Redis to confirm caching and click tracking are both functioning.
- Create a short URL by running this command in a new terminal:
curl -X POST http://localhost:3000/shorten \
-H "Content-Type: application/json" \
-d '{"long_url": "https://www.example.com/a-very-long-url-path"}'
What should I see?
You should see a JSON response with short_url, short_code, and long_url fields. Note the short_code value. You'll use it in the next commands.
- Test the redirect by hitting your short URL (replace the code with your short_code from above):
curl -i http://localhost:3000/[[SHORT_CODE="your short_code from the response above"]]
- Confirm you see a 302 Found status in the response headers, along with a Location header pointing to https://www.example.com/a-very-long-url-path.
Why use curl -i?
The -i flag includes the HTTP response headers in the output. By default, curl does not follow redirects, so you'll see the raw 302 response instead of being sent to the destination.
- Verify the Redis cache contains your URL mapping by running:
docker compose exec redis redis-cli GET "url:[[SHORT_CODE="your short_code"]]"
- You should see https://www.example.com/a-very-long-url-path printed in the terminal. This confirms the cache-aside pattern is working.
- Check that the click counter incremented by running:
docker compose exec redis redis-cli GET "clicks:[[SHORT_CODE="your short_code"]]"
- You should see "1" in the output. Each time you hit the redirect endpoint, this counter increments by one without blocking the response.
Not seeing the expected output?
- If curl returns Connection refused, make sure your server is still running. Return to the terminal where you ran npm start and check for errors.
- If you see a 404 response, double check you're using the exact short_code from your POST response. Short codes are case-sensitive.
- If the Redis GET returns (nil), your Docker containers may have restarted. Run docker compose ps to confirm both are healthy.
Help me debug my curl commands
✔️ Awesome, I've got everything!
Great. Double check that src/server.js is saved and your server is still running.
ⓧ I'd like to double check the full code
const express = require('express');
const pool = require('./db');
const redis = require('./redis');
const { encode } = require('./base62');
const config = require('./config');
const app = express();
app.use(express.json());
// POST /shorten - Create a short URL
app.post('/shorten', async (req, res) => {
const { long_url } = req.body;
if (!long_url) {
return res.status(400).json({ error: 'long_url is required' });
}
try {
new URL(long_url);
} catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
try {
const result = await pool.query(
'INSERT INTO urls (short_code, long_url) VALUES ($1, $2) RETURNING id, short_code',
['placeholder', long_url]
);
const id = result.rows[0].id;
const shortCode = encode(Number(id));
await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [shortCode, id]);
// Cache on write
await redis.set(`url:${shortCode}`, long_url, 'EX', config.cacheTtl);
res.status(201).json({
short_url: `${config.baseUrl}/${shortCode}`,
short_code: shortCode,
long_url: long_url,
});
} catch (err) {
console.error('Error creating short URL:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /:code/stats
app.get('/:code/stats', async (req, res) => {
const { code } = req.params;
try {
const result = await pool.query(
'SELECT long_url, click_count, created_at FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
const row = result.rows[0];
const pendingClicks = await redis.get(`clicks:${code}`);
const totalClicks = Number(row.click_count) + Number(pendingClicks || 0);
res.json({
short_code: code,
long_url: row.long_url,
total_clicks: totalClicks,
created_at: row.created_at,
});
} catch (err) {
console.error('Error fetching stats:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /:code - Redirect
app.get('/:code', async (req, res) => {
const { code } = req.params;
try {
// Check Redis cache first
let longUrl = await redis.get(`url:${code}`);
if (!longUrl) {
// Cache miss - query PostgreSQL
const result = await pool.query(
'SELECT long_url FROM urls WHERE short_code = $1',
[code]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
longUrl = result.rows[0].long_url;
// Populate cache
await redis.set(`url:${code}`, longUrl, 'EX', config.cacheTtl);
}
// Increment click counter in Redis (non-blocking, fire-and-forget)
redis.incr(`clicks:${code}`);
// 302 redirect preserves analytics (browser always comes back)
res.redirect(302, longUrl);
} catch (err) {
console.error('Error redirecting:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(config.port, () => {
console.log(`URL Shortener running on ${config.baseUrl}`);
});
Your read path is verified. Redis absorbs the vast majority of redirect traffic, and click analytics are tracked without adding a single millisecond to the response. Next up, you'll build the background worker that flushes those Redis counters into PostgreSQL for permanent storage.
Build the Async Analytics Worker
Right now, every redirect increments a Redis counter (clicks:{code}), but those counts never make it back to PostgreSQL. They sit in memory, vulnerable to disappearing if Redis restarts.
You need a background process that periodically drains those counters into the database. This is the same "fire-and-forget to buffer, flush async" pattern used by production analytics pipelines at scale.
In this step, get ready to:
- Write a background worker that flushes Redis click counters to PostgreSQL.
- Use atomic operations to prevent double-counting between flush cycles.
- Verify the stats endpoint returns accurate real-time counts.
Create the background worker
The worker is a standalone Node.js script that runs independently of your Express server. It wakes up on a timer, scans Redis for any buffered click counters, and writes them into PostgreSQL.
- In VS Code, create a new file called src/worker.js.
- Add the flush function by pasting this code:
const pool = require('./db');
const redis = require('./redis');
const config = require('./config');
async function flushClickCounts() {
try {
// Find all click counter keys
const keys = await redis.keys('clicks:*');
if (keys.length === 0) {
return;
}
console.log(`Flushing ${keys.length} click counters to PostgreSQL...`);
for (const key of keys) {
const shortCode = key.replace('clicks:', '');
// Atomically get and delete the counter
const count = await redis.getdel(key);
if (count && Number(count) > 0) {
await pool.query(
'UPDATE urls SET click_count = click_count + $1 WHERE short_code = $2',
[Number(count), shortCode]
);
}
}
console.log('Flush complete.');
} catch (err) {
console.error('Error flushing click counts:', err.message);
}
}
What does this code do?
The flushClickCounts function does three things in sequence:
- redis.keys('clicks:*') scans Redis for all buffered click counters.
- redis.getdel(key) atomically reads the counter value AND deletes the key in one operation. This prevents double-counting if two flush cycles overlap.
- The UPDATE query adds the buffered count to the existing click_count in PostgreSQL, preserving any previously flushed clicks.
Why GETDEL instead of separate GET and DEL?
If you read the counter with GET and then delete it with DEL as two separate commands, a new click could arrive between them. That click would be lost because the key gets deleted before the new increment is captured.
GETDEL solves this by making the read-and-delete atomic. Any click that arrives after GETDEL runs creates a fresh key and gets counted in the next flush cycle.
- Do not save the file yet. You still need to add the scheduling code below the function.
Implement atomic flush
The flush function exists, but nothing calls it yet. You need scheduling logic that runs the flush on an interval, triggers an initial flush on startup, and handles graceful shutdown.
- In src/worker.js, add this code below the flushClickCounts function:
// Run flush on interval
console.log(`Analytics worker started. Flushing every ${config.flushInterval / 1000}s`);
setInterval(flushClickCounts, config.flushInterval);
// Initial flush on startup
flushClickCounts();
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down worker...');
await flushClickCounts();
await pool.end();
redis.disconnect();
process.exit(0);
});
What does this code do?
- setInterval(flushClickCounts, config.flushInterval) runs the flush every 30 seconds (the value from config.js).
- The immediate flushClickCounts() call on startup captures any clicks that accumulated while the worker was offline.
- The SIGINT handler ensures that when you press Ctrl+C, any remaining counters get flushed to PostgreSQL before the process exits.
- Save src/worker.js.
- Open a new terminal panel and start the worker by running:
npm run worker
- You should see Analytics worker started. Flushing every 30s printed in the terminal.
Worker crashes on startup?
- If you see a connection error, make sure your Docker containers are still running. Run docker compose ps to check.
- If you see Cannot find module './db', confirm your file is saved inside the src/ folder.
- help me debug my worker startup error
Add a stats endpoint
Your GET /:code/stats endpoint already combines the PostgreSQL click_count with any pending Redis clicks. Now that the worker is flushing counters, you can test the full analytics pipeline end-to-end.
- Make sure your Express server is still running in its original terminal (npm start).
- Open a third terminal panel and create a test URL by running:
curl -s -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"long_url": "https://example.com/analytics-test"}'
- Copy the short_code from the response.
- Generate a few clicks by hitting the redirect endpoint multiple times:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/b
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/b
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/b
What are these curl flags?
The -o /dev/null flag discards the response body (since 302 redirects return HTML). The -w "%{http_code}" flag prints just the HTTP status code so you can confirm each request returns 302.
Replace b with whatever short code your POST /shorten response returned.
- Check the stats endpoint to see your click count:
curl -s http://localhost:3000/b/stats | python3 -m json.tool
- You should see a total_clicks value of 3 (or however many times you hit the redirect endpoint).
- Wait 30 seconds for the worker to flush, then check stats again. The total_clicks value should remain the same, but the clicks have now moved from Redis into PostgreSQL.
How does the stats endpoint stay accurate across flushes?
The GET /:code/stats handler adds the PostgreSQL click_count and the pending Redis clicks:{code} counter together. Before a flush, most clicks are in Redis. After a flush, they move to PostgreSQL. The sum is always correct regardless of when the flush runs.
Stats show 0 clicks?
- Make sure you used the correct short code in the stats URL. The code in the URL must match the one from your POST /shorten response.
- Confirm your redirect requests returned 302. If they returned 404, the short code does not exist in the database.
- help me debug why my stats endpoint shows zero clicks
✔️ Awesome, I've got everything!
Great. Double check you have saved src/worker.js and that your worker is running without errors.
ⓧ I'd like to double check the full code
const pool = require('./db');
const redis = require('./redis');
const config = require('./config');
async function flushClickCounts() {
try {
// Find all click counter keys
const keys = await redis.keys('clicks:*');
if (keys.length === 0) {
return;
}
console.log(`Flushing ${keys.length} click counters to PostgreSQL...`);
for (const key of keys) {
const shortCode = key.replace('clicks:', '');
// Atomically get and delete the counter
const count = await redis.getdel(key);
if (count && Number(count) > 0) {
await pool.query(
'UPDATE urls SET click_count = click_count + $1 WHERE short_code = $2',
[Number(count), shortCode]
);
}
}
console.log('Flush complete.');
} catch (err) {
console.error('Error flushing click counts:', err.message);
}
}
// Run flush on interval
console.log(`Analytics worker started. Flushing every ${config.flushInterval / 1000}s`);
setInterval(flushClickCounts, config.flushInterval);
// Initial flush on startup
flushClickCounts();
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down worker...');
await flushClickCounts();
await pool.end();
redis.disconnect();
process.exit(0);
});
Your analytics pipeline is fully wired up. Clicks flow from the redirect endpoint into Redis counters, the background worker flushes them to PostgreSQL every 30 seconds, and the stats endpoint gives you an accurate count at any moment. Next up, you will document the architectural decisions that shaped this system.
Document Architectural Decisions
You have a fully working URL shortener with three distinct data paths. The code tells what the system does, but it doesn't explain why you made each design choice.
In production, architectural decisions outlive the code they shape. An architecture decision log captures the reasoning so that future you (or your team) doesn't reverse a deliberate trade-off by accident. In this step, you'll write that log.
In this step, get ready to:
- Document four key architectural trade-offs in a decision log.
- Add back-of-envelope capacity math proving the design scales.
- Draw a text-based architecture diagram showing all three data paths.
Write the decision log
An architecture decision record (ADR) captures what you decided, why you chose it, and what you traded away. Each entry answers: what alternatives existed, and why did this option win for our use case?
- Create a new file called ARCHITECTURE.md in the root of your url-shortener folder (next to package.json).
- Add the following decision log content:
# Architecture Decision Log
## 1. Base62 Encoding over Hashing
**Decision**: Use Base62 encoding of auto-increment database IDs instead of hash-based short codes (e.g., truncated MD5/SHA).
**Why**: Base62 on sequential IDs is collision-free by construction. Every database row gets a unique ID, and the encoding is a deterministic one-to-one mapping. Hash-based approaches require collision detection and retry logic that becomes expensive at scale (birthday paradox at 10M+ URLs).
**Trade-off**: Short codes are sequential and guessable. If non-guessable codes were a requirement, we'd need a random approach with a uniqueness constraint and retry loop.
## 2. HTTP 302 over 301
**Decision**: Use 302 (Found) for redirects instead of 301 (Moved Permanently).
**Why**: HTTP 301 allows browsers to cache the redirect indefinitely. Subsequent visits skip our server entirely, which breaks click analytics. HTTP 302 forces the browser back to our server on every click, preserving per-click counting fidelity.
**Trade-off**: Slightly higher server load since every click hits the server. At scale, services like Bitly use 301 with `Cache-Control: private, max-age=90` as a middle ground.
## 3. Cache-Aside over Write-Through
**Decision**: Use cache-aside (lazy loading) on the read path with cache-on-write warming.
**Why**: URL shorteners are read-heavy (approximately 100:1 read-to-write ratio). Cache-aside with Redis absorbs 95%+ of redirect traffic, making PostgreSQL the lookup of last resort. We also warm the cache on write so the first redirect after creation is always a cache hit.
**Trade-off**: Cache misses on cold start or after TTL expiry add one database round-trip. Acceptable given the 24-hour TTL and the write-warming strategy.
## 4. Async Analytics over Synchronous Writes
**Decision**: Buffer click counts in Redis and flush to PostgreSQL via a background worker every 30 seconds.
**Why**: The redirect path must be as fast as possible. A synchronous database write on every click would add latency to the hot path. Redis INCR is atomic and O(1), adding negligible overhead to the redirect response.
**Trade-off**: Click counts in PostgreSQL are eventually consistent (up to 30 seconds stale). The stats endpoint compensates by reading both the database count and the pending Redis counter.
What does this document?
Each decision entry follows a consistent format: what you decided, why it wins for this use case, and what you gave up.
- Base62 over hashing eliminates collision handling entirely. The database's auto-increment guarantees uniqueness.
- 302 over 301 ensures every click passes through your server, keeping analytics accurate.
- Cache-aside absorbs read traffic while PostgreSQL only handles misses.
- Async analytics keeps the redirect path fast by deferring writes to a background worker.
- Save ARCHITECTURE.md.
- Confirm the file exists by running:
ls ARCHITECTURE.md
- You should see ARCHITECTURE.md in the output.
Add capacity reasoning
System design interviews and production readiness reviews both ask the same question: will this scale? Back-of-envelope math proves your design handles realistic traffic without guessing.
- In ARCHITECTURE.md, add the following capacity reasoning section below the decision log:
## Capacity Reasoning
### Short Code Space
- Alphabet: a-z, A-Z, 0-9 (62 characters)
- Code length: 7 characters
- Total combinations: 62^7 ≈ 3.5 trillion unique URLs
- At 1,000 new URLs per second, the space lasts ~111 years
### Traffic Model
- Assumed read-to-write ratio: 100:1
- If write throughput is 100 URLs/second, read throughput is 10,000 redirects/second
- Redis cache hit rate target: 95%+
- Database redirect queries (cache misses): ~500/second at peak
- PostgreSQL with connection pooling handles this comfortably
### Storage
- Average URL length: ~100 bytes
- Row overhead (indexes, metadata): ~200 bytes per URL
- 1 billion URLs ≈ 200 GB of PostgreSQL storage
- Redis cache (hot URLs only): ~10 GB for 50M cached mappings
Why does capacity math matter?
The key insight is that 7-character Base62 codes give approximately 3.5 trillion combinations. You will never run out of short codes for any realistic use case.
The 100:1 read-to-write ratio means your Redis cache handles the overwhelming majority of traffic. PostgreSQL only gets queried on cold starts and TTL expiry.
- Save ARCHITECTURE.md.
- You won't see a runtime change yet. The next substep adds the architecture diagram to complete the file.
Diagram the architecture
A text-based diagram lives in the same file as your code. It never goes stale in a separate drawing tool, and anyone reading the repo sees it immediately.
- At the bottom of ARCHITECTURE.md, add the following diagram section:
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT │
└──────────┬──────────────────────────────────┬───────────────────┘
│ │
│ POST /shorten │ GET /:code
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ EXPRESS API SERVER │
└──────┬──────────────────────┬───────────────────────┬───────────┘
│ │ │
│ WRITE PATH │ READ PATH │ ANALYTICS
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ PostgreSQL │ │ Redis │ │ Redis │
│ │ │ │ │ clicks:* │
│ urls table │ │ url:code │ │ counters │
│ INSERT row │ │ cache hit │ │ INCR │
└────────────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ cache miss? │ every 30s
│ │ ▼
│ ▼ ┌────────────┐
│ ┌────────────┐ │ WORKER │
│ │ PostgreSQL │ │ flush to │
│ │ SELECT │ │ PostgreSQL │
│ └────────────┘ └─────┬──────┘
│ │
│ UPDATE click_count │
│◄───────────────────────────────────────────┘
▼
┌────────────┐
│ Redis │
│ cache warm │
│ SET url:* │
└────────────┘
```
### Path Summary
- **WRITE PATH**: POST /shorten → INSERT into PostgreSQL → Base62(id) → SET Redis cache
- **READ PATH**: GET /:code → GET Redis → (miss?) SELECT PostgreSQL → SET Redis → 302 redirect
- **ANALYTICS**: GET /:code → INCR Redis clicks:{code} → Worker flushes to PostgreSQL click_count
What does the diagram show?
The diagram separates your system into three distinct data paths, each optimized for its purpose:
- Write path goes directly to PostgreSQL (source of truth) then warms Redis.
- Read path hits Redis first. PostgreSQL is only a fallback on cache miss.
- Analytics path buffers in Redis and flushes asynchronously. It never blocks the redirect.
- Save ARCHITECTURE.md.
- Open the VS Code command palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), then type Markdown: Open Preview and press Enter.
- You should see your decision log rendered with bold headings, the capacity math formatted as bullet lists, and the ASCII diagram displayed in a code block.
Diagram looks broken in preview?
Make sure the diagram is wrapped in triple backticks (a fenced code block in Markdown). Without the fences, the box-drawing characters won't align properly.
Also check that there are no extra spaces before or after the opening/closing backticks.
Help me fix my ARCHITECTURE.md diagram rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here's the complete ARCHITECTURE.md for reference:
# Architecture Decision Log
## 1. Base62 Encoding over Hashing
**Decision**: Use Base62 encoding of auto-increment database IDs instead of hash-based short codes (e.g., truncated MD5/SHA).
**Why**: Base62 on sequential IDs is collision-free by construction. Every database row gets a unique ID, and the encoding is a deterministic one-to-one mapping. Hash-based approaches require collision detection and retry logic that becomes expensive at scale (birthday paradox at 10M+ URLs).
**Trade-off**: Short codes are sequential and guessable. If non-guessable codes were a requirement, we'd need a random approach with a uniqueness constraint and retry loop.
## 2. HTTP 302 over 301
**Decision**: Use 302 (Found) for redirects instead of 301 (Moved Permanently).
**Why**: HTTP 301 allows browsers to cache the redirect indefinitely. Subsequent visits skip our server entirely, which breaks click analytics. HTTP 302 forces the browser back to our server on every click, preserving per-click counting fidelity.
**Trade-off**: Slightly higher server load since every click hits the server. At scale, services like Bitly use 301 with `Cache-Control: private, max-age=90` as a middle ground.
## 3. Cache-Aside over Write-Through
**Decision**: Use cache-aside (lazy loading) on the read path with cache-on-write warming.
**Why**: URL shorteners are read-heavy (approximately 100:1 read-to-write ratio). Cache-aside with Redis absorbs 95%+ of redirect traffic, making PostgreSQL the lookup of last resort. We also warm the cache on write so the first redirect after creation is always a cache hit.
**Trade-off**: Cache misses on cold start or after TTL expiry add one database round-trip. Acceptable given the 24-hour TTL and the write-warming strategy.
## 4. Async Analytics over Synchronous Writes
**Decision**: Buffer click counts in Redis and flush to PostgreSQL via a background worker every 30 seconds.
**Why**: The redirect path must be as fast as possible. A synchronous database write on every click would add latency to the hot path. Redis INCR is atomic and O(1), adding negligible overhead to the redirect response.
**Trade-off**: Click counts in PostgreSQL are eventually consistent (up to 30 seconds stale). The stats endpoint compensates by reading both the database count and the pending Redis counter.
## Capacity Reasoning
### Short Code Space
- Alphabet: a-z, A-Z, 0-9 (62 characters)
- Code length: 7 characters
- Total combinations: 62^7 ≈ 3.5 trillion unique URLs
- At 1,000 new URLs per second, the space lasts ~111 years
### Traffic Model
- Assumed read-to-write ratio: 100:1
- If write throughput is 100 URLs/second, read throughput is 10,000 redirects/second
- Redis cache hit rate target: 95%+
- Database redirect queries (cache misses): ~500/second at peak
- PostgreSQL with connection pooling handles this comfortably
### Storage
- Average URL length: ~100 bytes
- Row overhead (indexes, metadata): ~200 bytes per URL
- 1 billion URLs ≈ 200 GB of PostgreSQL storage
- Redis cache (hot URLs only): ~10 GB for 50M cached mappings
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT │
└──────────┬──────────────────────────────────┬───────────────────┘
│ │
│ POST /shorten │ GET /:code
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ EXPRESS API SERVER │
└──────┬──────────────────────┬───────────────────────┬───────────┘
│ │ │
│ WRITE PATH │ READ PATH │ ANALYTICS
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ PostgreSQL │ │ Redis │ │ Redis │
│ │ │ │ │ clicks:* │
│ urls table │ │ url:code │ │ counters │
│ INSERT row │ │ cache hit │ │ INCR │
└────────────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ cache miss? │ every 30s
│ │ ▼
│ ▼ ┌────────────┐
│ ┌────────────┐ │ WORKER │
│ │ PostgreSQL │ │ flush to │
│ │ SELECT │ │ PostgreSQL │
│ └────────────┘ └─────┬──────┘
│ │
│ UPDATE click_count │
│◄───────────────────────────────────────────┘
▼
┌────────────┐
│ Redis │
│ cache warm │
│ SET url:* │
└────────────┘
```
### Path Summary
- **WRITE PATH**: POST /shorten → INSERT into PostgreSQL → Base62(id) → SET Redis cache
- **READ PATH**: GET /:code → GET Redis → (miss?) SELECT PostgreSQL → SET Redis → 302 redirect
- **ANALYTICS**: GET /:code → INCR Redis clicks:{code} → Worker flushes to PostgreSQL click_count
Your URL shortener is complete. You have working code and the reasoning behind every major decision documented in one place. Next up, the Secret Mission challenges you to extend this architecture with TTL-based URL expiration.
Secret mission
Add URL Expiration with TTL Support
Your URL shortener creates permanent links. But what about temporary marketing campaigns, event registrations, or one-time-use invite links? Add TTL-based expiration so links self-destruct after a configurable time, returning 410 Gone to anyone who tries to use them after expiry.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers: PostgreSQL 16 and Redis 7 (running locally via Docker Compose).
- Local project files: the url-shortener directory on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the URL shortener.
- Your Docker containers consume minimal CPU and memory when idle.
- The PostgreSQL and Redis containers will restart automatically if Docker Desktop is running.
- Your project files remain in the url-shortener folder on your Desktop.
Pause - I'll come back to this later
Shut down running containers to free up memory. Your data and files stay intact for when you return.
- Stop the Docker containers by running this command in your url-shortener directory:
docker compose stop
- Your data volumes are preserved. When you're ready to resume, run:
docker compose start
- Your project files remain untouched on your Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, data volumes, and project files permanently.
- Navigate to your project directory and remove the Docker containers along with their data volumes:
cd ~/Desktop/url-shortener
docker compose down -v
- This removes both the PostgreSQL and Redis containers and deletes all stored URL data.
- Delete the project directory. On macOS/Linux:
cd ~/Desktop
rm -rf url-shortener
- On Windows:
cd ~\Desktop
rmdir /s /q url-shortener
- Confirm the folder is gone by running:
ls ~/Desktop
- You should no longer see url-shortener in the output.
Nice Work!
Nice Work!
You just built a production-architecture URL shortener from scratch. The patterns you implemented here (layered caching, async analytics, collision-free ID generation) are the same ones powering services that handle billions of redirects daily.
You've learned how to:
- Implement Base62 encoding on auto-increment IDs for collision-free short codes, backed by PostgreSQL persistence and a cache-aside Redis layer that absorbs 95%+ of redirect traffic.
- Decouple click analytics from the redirect hot path using atomic Redis counters and a background flush worker that batch-updates PostgreSQL every 30 seconds.
- Make deliberate architectural trade-offs (302 for analytics, async for speed, counter-based over hash-based IDs) and document them in a decision log with capacity math.
- Add TTL-based URL expiration with proper 410 Gone responses for expired links.
Ready to quiz yourself?