Design and Build a URL Shortener

Build a URL shortener API with caching, and document trade-offs for interviews.

Introduction

30 Second Summary

Every time you click a shortened link on social media or in a text message, a system is translating that tiny URL back to the full address in milliseconds. That translation involves API design, database lookups, and caching.

In this project, you will build a URL shortener from scratch using Node.js, design its architecture, and document every trade-off decision the way an interviewer expects you to.

What You'll Build

A Node.js REST API that generates short codes for long URLs, stores mappings in SQLite, and uses an in-memory cache for fast redirects.

By the end of this project, you'll have:

  • A working URL shortener API with POST /api/shorten and GET /:code endpoints handling creation and redirection.
  • An architecture diagram in Excalidraw showing all system components and data flow, ready to whiteboard in an interview.
  • A DESIGN.md file documenting trade-off decisions for every architectural choice.
  • Secret Mission: Implement a token bucket rate limiter to protect the API from abuse.

Are there any prerequisites?

This project assumes basic Node.js and Express knowledge, along with familiarity with HTTP methods like GET and POST. No prior system design experience is needed.

Before We Start

The URL shortener is one of the most common system design interview questions for freshers. It shows up in roughly half of new-grad design rounds because it covers so many core concepts: API design, database schema, caching, and scaling.

In this step, you'll set up your development environment and project structure. You'll also create a design document to record your architectural decisions as you build.

In this step, get ready to:
  • Verify your Node.js installation and initialize the project.
  • Install the project dependencies.
  • Create the project file structure and document your system requirements.
Verify Node.js and initialize the project

This project uses Node.js as the runtime for your URL shortener server. You need version 20 or higher installed on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Check your Node.js version by running this command:
node --version

You should see a version number starting with v20 or higher (e.g. v22.15.0 or v24.1.0).

Don't see v20 or higher?

If you see a version below 20, visit the official Node.js downloads page at nodejs.org and install the latest LTS version. If you see "command not found", Node.js is not installed yet. Visit the same link to download and install it.

Help me install Node.js v20 or higher on my machine.

Now you'll create the project directory on your Desktop and initialize it with npm.

  • 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
  • Initialize the project with a default package.json by running:
npm init -y

What does npm init -y do?

The npm init -y command creates a package.json file with default values. This file tracks your project's metadata and dependencies.

You should see output confirming the package.json file was created.

Install dependencies

Your URL shortener needs three packages: a web framework for handling HTTP requests, a database driver for persistence, and a caching library for fast lookups.

  • Install all required packages by running this command:
npm install express@5.2.1 better-sqlite3@12.10.0 node-cache@5.1.2

What are these packages?

  • express@5.2.1 is a web framework for building your API endpoints.
  • better-sqlite3@12.10.0 is a fast, synchronous SQLite driver for storing URL mappings in a database.
  • node-cache@5.1.2 is an in-memory cache library for speeding up repeated lookups without hitting the database.
  • Verify the packages installed correctly by running:
node -e "require('express'); require('better-sqlite3'); require('node-cache'); console.log('All dependencies loaded')"

You should see All dependencies loaded printed in your terminal without any errors.

Seeing a module not found error?

Make sure you ran the npm install command from inside the url-shortener directory. Check that a node_modules folder exists in your project directory.

If you see a compilation error related to better-sqlite3, your Node.js version may not have a prebuilt binary available. Try upgrading to Node.js 22 or higher.

Help me fix my dependency installation error.

Create project structure and DESIGN.md

A well-organized file structure makes your project easier to understand during an interview. Each file will handle a single responsibility.

  • Create all your project files by running this command:
touch server.js db.js cache.js routes.js utils.js DESIGN.md
  • Confirm the files exist by running:
ls

You should see server.js, db.js, cache.js, routes.js, utils.js, and DESIGN.md listed alongside package.json and node_modules.

What is each file for?

  • server.js starts the Express server and listens for connections.
  • db.js handles database setup and queries.
  • cache.js manages the in-memory cache layer.
  • routes.js defines your API endpoint handlers.
  • utils.js contains helper functions like short code generation.
  • DESIGN.md documents your architectural decisions. This is your interview artifact.

Now you'll write your first design entry. In system design interviews, interviewers expect you to start by clarifying requirements before jumping into architecture.

  • 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 on the left, click DESIGN.md to open it.
  • Paste the following requirements into DESIGN.md:
# URL Shortener Design

## Requirements

### Functional Requirements
- Shorten a long URL to a short code
- Redirect a short code to the original URL

### Non-Functional Requirements
- Low latency redirects
- High read-to-write ratio (~100:1)
- URL-safe short codes

Why start with requirements?

In a system design interview, the first 30 seconds should be spent clarifying what the system needs to do (functional requirements) and how well it needs to do it (non-functional requirements).

The ~100:1 read-to-write ratio is the most important non-functional requirement here. People create short URLs occasionally, but those URLs get clicked hundreds of times. This ratio drives every architectural decision you'll make in the next steps.

  • Save DESIGN.md by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

# URL Shortener Design

## Requirements

### Functional Requirements
- Shorten a long URL to a short code
- Redirect a short code to the original URL

### Non-Functional Requirements
- Low latency redirects
- High read-to-write ratio (~100:1)
- URL-safe short codes

Your environment is ready and your design document has its first entry. Next up, you'll design the REST API endpoints and database schema, making architectural trade-offs along the way.

Design the REST API and Database Schema

Your project structure is in place, and all dependencies are installed. Now it's time to make the architectural decisions that will shape your URL shortener.

In this step, you'll design two REST endpoints, create a database schema optimized for fast lookups, and build a short code generator with collision handling. Each decision gets documented in DESIGN.md, building the interview artifact alongside the code.

In this step, get ready to:
  • Define REST API endpoints and implement the Express server.
  • Design and implement the SQLite database schema.
  • Implement short code generation with collision handling.
Define API endpoints and implement the Express server

A URL shortener needs exactly two operations: creating a short URL, and redirecting via a short code. These map to two REST endpoints.

  • POST /api/shorten accepts a JSON body with a URL and returns the shortened version.
  • GET /:code looks up the short code and redirects the browser to the original URL with an HTTP 302 response.

The Express app lives in server.js and delegates all route handling to routes.js.

  • Open server.js in VS Code and add the Express app setup:
const express = require('express');
const routes = require('./routes');

const app = express();
const PORT = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Delegate all routes to the routes module
app.use('/', routes);

// Start the server
app.listen(PORT, () => {
  console.log(`URL Shortener running on http://localhost:${PORT}`);
});

What does this code do?

This is the entry point for the entire application.

  • express.json() is middleware that parses JSON request bodies so your route handlers can access req.body.
  • app.use('/', routes) mounts all routes defined in routes.js at the root path.
  • The server listens on port 3000, which is where you'll send requests during development.
  • Save server.js.

You won't see a change yet. The server can't start until routes.js and its dependencies exist.

  • Open routes.js and add the imports, base URL constant, and the POST /api/shorten handler:
const express = require('express');
const router = express.Router();
const { insertUrl, findByCode, incrementClicks } = require('./db');
const cache = require('./cache');
const { generateCode } = require('./utils');

const BASE_URL = 'http://localhost:3000';

// POST /api/shorten - Create a new short URL
router.post('/api/shorten', (req, res) => {
  const { url } = req.body;

  // Validate that a URL was provided
  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  // Validate URL format using the built-in URL constructor
  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: 'Invalid URL format' });
  }

  // Generate a unique short code and store the mapping
  const code = generateCode();
  insertUrl.run(code, url);

  res.status(201).json({
    shortUrl: `${BASE_URL}/${code}`,
    code: code
  });
});

What does this code do?

The POST handler validates the incoming URL in two stages. First, it checks whether a URL was provided at all. Then it uses the built-in URL constructor to verify the format is valid (catches typos like missing https://).

The imports at the top pull in cache and incrementClicks which you'll use in the next step when adding the cache layer and click tracking. They're harmless for now since the POST route doesn't reference them.

  • Below the POST handler, add the GET /:code redirect route and the module export:
// GET /:code - Redirect to the original URL
router.get('/:code', (req, res) => {
  const { code } = req.params;
  const record = findByCode.get(code);

  // Return 404 if the short code doesn't exist
  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  // Redirect with HTTP 302 (temporary redirect)
  res.redirect(302, record.original_url);
});

module.exports = router;

Why HTTP 302 instead of 301?

A 302 (temporary redirect) tells the browser to check back with your server every time. This is important because it lets you track clicks and change the destination later.

A 301 (permanent redirect) would cause browsers to cache the redirect and skip your server entirely on future visits.

  • Save routes.js.

You still can't run the server because routes.js depends on db.js and utils.js, which are still empty.

✔️ Awesome, I've got everything!

Great. Double check you've saved both server.js and routes.js.

ⓧ I'd like to double check the full code

Here's the complete server.js:

const express = require('express');
const routes = require('./routes');

const app = express();
const PORT = 3000;

app.use(express.json());
app.use('/', routes);

app.listen(PORT, () => {
  console.log(`URL Shortener running on http://localhost:${PORT}`);
});

And the complete routes.js:

const express = require('express');
const router = express.Router();
const { insertUrl, findByCode, incrementClicks } = require('./db');
const cache = require('./cache');
const { generateCode } = require('./utils');

const BASE_URL = 'http://localhost:3000';

router.post('/api/shorten', (req, res) => {
  const { url } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: 'Invalid URL format' });
  }

  const code = generateCode();
  insertUrl.run(code, url);

  res.status(201).json({
    shortUrl: `${BASE_URL}/${code}`,
    code: code
  });
});

router.get('/:code', (req, res) => {
  const { code } = req.params;
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.redirect(302, record.original_url);
});

module.exports = router;

Now document your first trade-off decision.

  • Open DESIGN.md and add a new ## Trade-offs section below the Requirements section with the following content:
## Trade-offs

### Why REST over GraphQL?
REST is simpler for this use case (2 endpoints). GraphQL adds complexity without benefit for a redirect-heavy service.

Why document trade-offs?

In system design interviews, interviewers want to hear you articulate WHY you chose an approach. Saying "I chose REST because it's simpler for a two-endpoint service" shows you evaluated alternatives and picked the right tool.

  • Save DESIGN.md.
Design and implement the database schema

Your URL shortener needs persistent storage for the mapping between short codes and original URLs. The database schema determines how fast lookups happen and what data you can track over time.

  • Open db.js and add the full database setup:
const Database = require('better-sqlite3');
const path = require('path');

// Create or open the SQLite database file
const db = new Database(path.join(__dirname, 'urls.db'));

// Enable Write-Ahead Logging for better concurrent read performance
db.pragma('journal_mode = WAL');

// Create the urls table if it doesn't exist
db.exec(`
  CREATE TABLE IF NOT EXISTS urls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    code TEXT UNIQUE NOT NULL,
    original_url TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    clicks INTEGER DEFAULT 0
  )
`);

// Create an index on the code column for fast lookups
db.exec(`
  CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code)
`);

// Prepared statements for common operations
const insertUrl = db.prepare('INSERT INTO urls (code, original_url) VALUES (?, ?)');
const findByCode = db.prepare('SELECT * FROM urls WHERE code = ?');
const incrementClicks = db.prepare('UPDATE urls SET clicks = clicks + 1 WHERE code = ?');

module.exports = { insertUrl, findByCode, incrementClicks };

What does this code do?

  • db.pragma('journal_mode = WAL') enables Write-Ahead Logging, which allows reads to happen simultaneously with writes. This is ideal for a read-heavy URL shortener.
  • The UNIQUE constraint on code prevents duplicate short codes at the database level.
  • The index on code makes lookups O(log n) instead of O(n). Without it, every redirect would scan the entire table.
  • Prepared statements (db.prepare) are pre-compiled SQL. They're faster and protect against SQL injection.
  • Save db.js.

You won't see a change yet. The database file urls.db gets created when the server first starts and requires this module.

Seeing a native module error?

If you see an error about prebuilt binaries when better-sqlite3 loads, your Node.js version might not have a matching prebuild. Verify you're running Node.js v20 or higher with node --version.

help me fix a better-sqlite3 native module error

✔️ Awesome, I've got everything!

Great. Double check you've saved db.js.

ⓧ I'd like to double check the full code

const Database = require('better-sqlite3');
const path = require('path');

const db = new Database(path.join(__dirname, 'urls.db'));

db.pragma('journal_mode = WAL');

db.exec(`
  CREATE TABLE IF NOT EXISTS urls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    code TEXT UNIQUE NOT NULL,
    original_url TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    clicks INTEGER DEFAULT 0
  )
`);

db.exec(`
  CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code)
`);

const insertUrl = db.prepare('INSERT INTO urls (code, original_url) VALUES (?, ?)');
const findByCode = db.prepare('SELECT * FROM urls WHERE code = ?');
const incrementClicks = db.prepare('UPDATE urls SET clicks = clicks + 1 WHERE code = ?');

module.exports = { insertUrl, findByCode, incrementClicks };
  • Add the SQL vs NoSQL trade-off entry below the REST vs GraphQL entry in DESIGN.md:
### Why SQL (SQLite) over NoSQL?
URL mappings are simple key-value pairs with a fixed schema. SQL gives us ACID transactions and indexing for fast lookups. At production scale, you might pick a key-value store like DynamoDB for horizontal scaling, but SQLite demonstrates the core concepts without infrastructure overhead.
  • Save DESIGN.md.
Implement short code generation

Every URL that gets shortened needs a unique 7-character code. The generation strategy must produce URL-safe characters and handle the rare case where two random codes collide.

  • Open utils.js and add the code generation function:
const crypto = require('crypto');
const { findByCode } = require('./db');

// Generate a unique 7-character URL-safe short code
function generateCode() {
  let code;
  let attempts = 0;
  const maxAttempts = 10;

  do {
    // Generate 5 random bytes and encode as base64url, then trim to 7 chars
    code = crypto.randomBytes(5).toString('base64url').slice(0, 7);
    attempts++;
    if (attempts > maxAttempts) {
      throw new Error('Failed to generate unique code after maximum attempts');
    }
  } while (findByCode.get(code));

  return code;
}

module.exports = { generateCode };

What does this code do?

  • crypto.randomBytes(5) generates 5 cryptographically random bytes (40 bits of randomness).
  • .toString('base64url') encodes those bytes using URL-safe characters (letters, digits, hyphens, underscores). No special characters that would break a URL.
  • .slice(0, 7) trims the result to exactly 7 characters. With 64 possible characters per position, that's 64^7 (over 4 billion) possible codes.
  • The do...while loop retries up to 10 times if a collision occurs. It checks the database each attempt to ensure uniqueness.
  • Save utils.js.

Module not found error?

If you see an error about ./db not being found, make sure db.js is saved in the same folder as utils.js. Both files must live in the url-shortener directory.

help me fix a module not found error in my Node.js project

✔️ Awesome, I've got everything!

Great. Double check you've saved utils.js.

ⓧ I'd like to double check the full code

const crypto = require('crypto');
const { findByCode } = require('./db');

function generateCode() {
  let code;
  let attempts = 0;
  const maxAttempts = 10;

  do {
    code = crypto.randomBytes(5).toString('base64url').slice(0, 7);
    attempts++;
    if (attempts > maxAttempts) {
      throw new Error('Failed to generate unique code after maximum attempts');
    }
  } while (findByCode.get(code));

  return code;
}

module.exports = { generateCode };
  • Add the final trade-off entry to DESIGN.md below the SQL vs NoSQL section:
### Why random codes vs. sequential/hash-based?
Random codes are unpredictable (security), but have collision risk. Hash-based (e.g., MD5 of URL) gives deterministic output but leaks information. Sequential (auto-increment encoded) is predictable. For a URL shortener, random with collision retry is the standard choice.
  • Save DESIGN.md.

All your files are ready. Time to test the full system working together.

  • Start the server by running this command in your terminal:
node server.js
  • You should see this output in the terminal:
URL Shortener running on http://localhost:3000
  • Open a second terminal and test the POST endpoint by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://example.com\"}"
  • You should see a JSON response with a shortUrl field, like:
{
  "shortUrl": "http://localhost:3000/aBcDeFg",
  "code": "aBcDeFg"
}

What should I see?

Your exact code value will be different (it's randomly generated each time). The important thing is that you get a 201 response with both shortUrl and code fields.

Connection refused or empty response?

  • Make sure the first terminal still shows the server running. If it crashed, check for syntax errors in the error output.
  • Confirm you're running the curl command in a separate terminal window, not the same one running the server.
  • If you see a "Cannot find module" error, double check that all files (server.js, routes.js, db.js, utils.js, cache.js) are saved in the url-shortener folder.

help me debug why my URL shortener server won't start

Your API is accepting URLs and generating short codes backed by a real database. Next up, you'll add an in-memory cache layer so redirect lookups skip the database entirely for popular URLs.

Add the Cache Layer

Your URL shortener works, but every single redirect hits the database. In a real production system with a 100:1 read-to-write ratio, that means 100 database queries for every URL created.

A cache layer solves this by storing frequently accessed URLs in memory, so repeated lookups skip the database entirely. In this step, you will implement the cache-aside pattern, track clicks, and document the trade-offs behind your caching decisions.

In this step, get ready to:
  • Implement the cache-aside pattern for fast redirects.
  • Test and observe cache HIT vs MISS behavior.
  • Add click tracking and a stats endpoint.
Implement cache-aside pattern

The cache-aside pattern means your application checks the cache first, and only queries the database on a cache miss. After a miss, it stores the result in the cache so the next request is fast.

  • In VS Code, open cache.js and add this code:
const NodeCache = require('node-cache');

// Cache with 5-minute TTL and 60-second cleanup interval
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });

module.exports = cache;

What does this code do?

stdTTL: 300 sets a 5-minute time-to-live for each cached entry. After 5 minutes, the entry expires automatically.

checkperiod: 60 tells node-cache to scan for expired keys every 60 seconds and clean them up.

  • Save cache.js.

You won't see a visible change yet. The next chunk wires this cache into the redirect route.

Now you need to update the redirect route to use the cache. Your current GET /:code handler goes straight to the database every time. You will replace it with a version that checks the cache first.

  • In routes.js, find the existing GET /:code handler:
router.get('/:code', (req, res) => {
  const { code } = req.params;
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.redirect(302, record.original_url);
});
  • Replace that entire handler with this cache-aside version:
router.get('/:code', (req, res) => {
  const { code } = req.params;

  // Step 1: Check cache first
  const cachedUrl = cache.get(code);
  if (cachedUrl) {
    console.log(`Cache HIT for code: ${code}`);
    return res.redirect(302, cachedUrl);
  }

  // Step 2: Cache miss - query the database
  console.log(`Cache MISS for code: ${code}`);
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  // Step 3: Store in cache for next time
  cache.set(code, record.original_url);
  res.redirect(302, record.original_url);
});

What does this code do?

This is the cache-aside pattern in three steps. First, check if the short code exists in the cache. If it does (HIT), redirect immediately without touching the database.

If the code is not cached (MISS), query the database, store the result in cache with cache.set(), then redirect. The next request for this same code will be a HIT.

  • Save routes.js.

You will test this in the next substep by watching the console output switch from MISS to HIT.

Seeing a syntax error?

Make sure you replaced the entire old handler (including the closing });) with the new one. Having two router.get('/:code') blocks will cause unexpected routing behavior.

Why is my cache-aside redirect not working?

Test and observe cache behavior

Time to see the cache-aside pattern in action. You will create a short URL, then visit it multiple times and watch the console output flip from MISS to HIT.

  • Start your server by running:
node server.js
  • Open a second terminal and create a short URL by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://example.com\"}"
  • Copy the code value from the JSON response (e.g. abc1234).
  • Visit the redirect endpoint twice by running this command two times (replace the code with yours):
curl -v http://localhost:3000/[[SHORTCODE="your short code from the response above"]]
  • Switch back to the terminal running your server and check the console output.

You should see Cache MISS for the first request and Cache HIT for the second. The second request never touched the database.

Seeing MISS every time?

Make sure you are using the same short code in both requests. If you used different codes, each one triggers its own first-time MISS. Also confirm that cache.set(code, record.original_url) is present in the MISS branch of your handler.

Why am I getting Cache MISS on every request?

Now document the trade-off decisions you just made. These are exactly the kind of reasoning interviewers want to hear.

  • Stop the server with Ctrl+C.
  • Open DESIGN.md and add these two entries under the existing ## Trade-offs section:
### Why cache-aside over write-through?
For a read-heavy service (100:1 ratio), cache-aside is ideal because we only cache URLs that are actually accessed. Write-through would cache every URL on creation, wasting memory on URLs that might never be visited.

### Why in-memory cache vs Redis?
In-memory works for single-server deployments. Redis is needed when you have multiple servers that need shared cache.

Why does this matter in interviews?

Interviewers don't expect freshers to pick the "right" answer. They want to hear you reason about trade-offs. Stating "cache-aside is better for read-heavy services because it only caches what's actually accessed" shows you understand the problem, not just the tool.

  • Save DESIGN.md.
Add click tracking

Analytics matter for any real URL shortener. You need to know how many times each short URL gets visited. Your database already has a clicks column. Now you will increment it on every redirect and expose it through a stats endpoint.

  • In routes.js, find this line inside the cache HIT branch:
    console.log(`Cache HIT for code: ${code}`);
    return res.redirect(302, cachedUrl);
  • Add incrementClicks.run(code) between those two lines, so it looks like this:
    console.log(`Cache HIT for code: ${code}`);
    incrementClicks.run(code);
    return res.redirect(302, cachedUrl);
  • Now find this line in the cache MISS branch:
  cache.set(code, record.original_url);
  res.redirect(302, record.original_url);
  • Add incrementClicks.run(code) between those two lines, so it looks like this:
  cache.set(code, record.original_url);
  incrementClicks.run(code);
  res.redirect(302, record.original_url);

Now you need an endpoint to actually read those click counts. This stats endpoint returns the click count and creation date for any short URL.

  • In routes.js, add this new route between the POST /api/shorten handler and the GET /:code handler:
router.get('/api/stats/:code', (req, res) => {
  const { code } = req.params;
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.json({
    code: record.code,
    originalUrl: record.original_url,
    clicks: record.clicks,
    createdAt: record.created_at
  });
});

Why does the stats route go before GET /:code?

Express matches routes in order. GET /:code is a catch-all that matches any path segment. If it came first, a request to /api/stats/abc123 would match /:code with code = "api". More specific routes must always come first.

  • Save routes.js.
  • Start the server by running:
node server.js
  • Create a new short URL by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://example.com\"}"
  • Visit the redirect endpoint 3 times using the code from the response:
curl http://localhost:3000/[[SHORTCODE="your short code from the response above"]]
  • Check the stats endpoint by running:
curl http://localhost:3000/api/stats/[[SHORTCODE="your short code from the response above"]]

You should see a JSON response with "clicks": 3. In the server terminal, the first request shows Cache MISS and requests 2-3 show Cache HIT.

Why track clicks after the redirect?

The redirect is the critical path. Users care about getting to their destination fast. Click tracking is a write operation that can happen after the redirect fires. This is the concept of decoupling write paths from the latency-critical read path.

Stats showing 0 clicks?

Make sure you added incrementClicks.run(code) in BOTH the cache HIT branch and the cache MISS branch. If it is only in one branch, clicks from the other path are not counted.

Also check that you are using the same short code for all 3 visits and the stats request.

Why is my click count wrong?

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point.

const NodeCache = require('node-cache');

const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });

module.exports = cache;
const express = require('express');
const router = express.Router();
const { insertUrl, findByCode, incrementClicks } = require('./db');
const cache = require('./cache');
const { generateCode } = require('./utils');

const BASE_URL = 'http://localhost:3000';

router.post('/api/shorten', (req, res) => {
  const { url } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: 'Invalid URL format' });
  }

  const code = generateCode();
  insertUrl.run(code, url);

  res.status(201).json({
    shortUrl: `${BASE_URL}/${code}`,
    code: code
  });
});

router.get('/api/stats/:code', (req, res) => {
  const { code } = req.params;
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.json({
    code: record.code,
    originalUrl: record.original_url,
    clicks: record.clicks,
    createdAt: record.created_at
  });
});

router.get('/:code', (req, res) => {
  const { code } = req.params;

  const cachedUrl = cache.get(code);
  if (cachedUrl) {
    console.log(`Cache HIT for code: ${code}`);
    incrementClicks.run(code);
    return res.redirect(302, cachedUrl);
  }

  console.log(`Cache MISS for code: ${code}`);
  const record = findByCode.get(code);

  if (!record) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  cache.set(code, record.original_url);
  incrementClicks.run(code);
  res.redirect(302, record.original_url);
});

module.exports = router;
# URL Shortener Design

## Requirements

### Functional Requirements
- Shorten a long URL to a short code
- Redirect a short code to the original URL

### Non-Functional Requirements
- Low latency redirects
- High read-to-write ratio (~100:1)
- URL-safe short codes

## Trade-offs

### Why REST over GraphQL?
REST is simpler for this use case (2 endpoints). GraphQL adds complexity without benefit for a redirect-heavy service.

### Why SQL (SQLite) over NoSQL?
URL mappings are simple key-value pairs with a fixed schema. SQL gives us ACID transactions and indexing for fast lookups. At production scale, you might pick a key-value store like DynamoDB for horizontal scaling, but SQLite demonstrates the core concepts without infrastructure overhead.

### Why random codes vs. sequential/hash-based?
Random codes are unpredictable (security), but have collision risk. Hash-based (e.g., MD5 of URL) gives deterministic output but leaks information. Sequential (auto-increment encoded) is predictable. For a URL shortener, random with collision retry is the standard choice.

### Why cache-aside over write-through?
For a read-heavy service (100:1 ratio), cache-aside is ideal because we only cache URLs that are actually accessed. Write-through would cache every URL on creation, wasting memory on URLs that might never be visited.

### Why in-memory cache vs Redis?
In-memory works for single-server deployments. Redis is needed when you have multiple servers that need shared cache.

Your URL shortener now has a proper cache layer with observable HIT/MISS behavior and click analytics. Next up, you will draw the full architecture diagram and document how this system scales.

Build the Architecture Diagram and Document Scaling

Your URL shortener is fully functional. It shortens URLs, redirects with caching, and tracks clicks.

In a system design interview, working code is only half the story. Interviewers want to see that you can visualize architecture and explain how the system scales. In this step, you'll draw the full system diagram and document your scaling strategy.

In this step, get ready to:
  • Draw a system architecture diagram in Excalidraw.
  • Document scaling decisions in DESIGN.md.
  • Write a 2-minute interview walkthrough narrative.
Draw the system architecture in Excalidraw

A system design interview typically starts at the whiteboard. Your diagram shows the interviewer that you understand how components connect and where data flows.

What is Excalidraw?

Excalidraw is a free, browser-based whiteboard tool. No account is needed. Your drawings save in your browser's local storage, and you can export them as PNG files for your portfolio.

You'll draw a diagram with 5-7 boxes representing the major components of your URL shortener. Here's the layout to follow:

  • Select the Rectangle tool (keyboard shortcut R) and draw your first box on the left side of the canvas.
  • Double-click the box to add text and label it Client.
  • Draw a second box to the right and label it API Server (Express).
  • Draw a box above the API Server and label it Cache (node-cache).
  • Draw a box to the right of the API Server and label it Database (SQLite).

Now connect the boxes with labeled arrows showing how data flows:

  • Select the Arrow tool (keyboard shortcut A) to draw connections between boxes.
  • Draw an arrow from Client to API Server and label it "HTTP requests".
  • Draw an arrow from API Server to Cache and label it "cache lookup".
  • Draw an arrow from API Server to Database and label it "DB query".
  • Draw an arrow from API Server back to Client and label it "302 redirect".

Finally, add text annotations to the diagram:

  • Select the Text tool (keyboard shortcut T) and add these annotations near the relevant boxes:
  • Near the Client/API Server arrow: "~100:1 read:write ratio".
  • Near the Cache box: "Cache TTL: 5 min".
  • Near the Database box: "Index on code column".

Why annotate the diagram?

Annotations transform a basic box diagram into an architecture diagram. They show the interviewer you understand the system's characteristics without needing to be asked.

The read:write ratio, cache TTL, and index are the three most critical performance decisions in your design. Putting them on the diagram means you can point to them as you speak.

  • Verify your diagram has 5-7 boxes with labeled arrows showing the complete data flow from client request to redirect response.

Add at-scale alternatives

Optionally add parenthetical notes on the Cache and Database boxes like "(Redis at scale)" and "(PostgreSQL at scale)". This shows the interviewer you know where the system would change under load.

Document scaling decisions in DESIGN.md

Interviewers always ask "how would you scale this?" after you present a design. Having your scaling analysis already documented means you can answer immediately and confidently.

  • Switch back to VS Code and open DESIGN.md.
  • Scroll to the bottom of the file (after the Trade-offs section) and add the following Scaling section:
## Scaling

### Vertical Scaling
Add more RAM/CPU to handle more concurrent connections.

### Horizontal Scaling
Multiple API server instances behind a load balancer, shared Redis cache, read replicas for the database.

### Database Choice at Scale
Migrate from SQLite to PostgreSQL (for concurrent writes) or DynamoDB/Cassandra (for massive horizontal scale).

### CAP Theorem
For a URL shortener, availability matters more than strict consistency (AP system). A short URL being available 1 second late is acceptable; being unavailable is not.

### What I Would Change at 10x Scale
- Add a CDN for popular redirects
- Use a distributed cache (Redis Cluster)
- Switch to a key-value store
- Add monitoring

What does each subsection cover?

Vertical Scaling is the simplest approach. Throw more hardware at one machine. It has a ceiling, but it is where most teams start.

Horizontal Scaling means running multiple copies of the API server behind a load balancer. This is where your in-memory cache breaks (each server has its own cache) and you need a shared cache like Redis.

The CAP theorem section shows you understand distributed systems trade-offs. For a URL shortener, being available (AP) matters more than being perfectly consistent (CP).

  • Save DESIGN.md.

Not sure what to write for scaling?

Think about what breaks first under load. SQLite cannot handle concurrent writes from multiple servers. The in-memory cache is local to one process. These are the bottlenecks that each scaling subsection addresses.

Help me understand which components become bottlenecks in my URL shortener at scale.

Practice the interview narrative

A 2-minute summary is the format most interviewers expect. You walk through requirements, point at your diagram, explain the data flow, and close with one trade-off stated clearly.

  • In DESIGN.md, scroll to the very top of the file (just below the # URL Shortener Design title).
  • Add the following Interview Walkthrough section above the Requirements section:
## Interview Walkthrough

1. **Requirements (30s)**: We need to shorten URLs and redirect short codes to originals. Non-functionally: low latency redirects, ~100:1 read-to-write ratio, URL-safe codes.
2. **High-level design (30s)**: Client hits API server, which checks an in-memory cache before querying the database. Short codes are random 7-character base64url strings.
3. **Core components and data flow (60s)**: POST /api/shorten generates a code, stores it in SQLite, returns the short URL. GET /:code checks cache first (cache-aside), falls back to DB on miss, then 302 redirects.
4. **Trade-off**: I chose cache-aside because with a 100:1 read:write ratio, we only want to cache URLs that are actually accessed, not every URL on creation.

Why this structure works in interviews

The 4-part structure (Requirements, High-level design, Core components, Trade-off) mirrors how senior engineers present designs. Each section has a time budget that keeps you on track.

Ending with a clearly stated trade-off is the key differentiator. Most freshers describe what they built. Strong candidates explain why they made specific choices over alternatives.

  • Save DESIGN.md.
  • Read the Interview Walkthrough section out loud as if you were presenting to an interviewer. Time yourself to aim for 2 minutes.

Point at the diagram as you speak

During step 2 (High-level design) and step 3 (Core components), point at your Excalidraw diagram. In a real interview you would be pointing at a whiteboard. Practicing the gesture now builds muscle memory.

Struggling to stay under 2 minutes?

Cut detail from section 3 (Core components). You do not need to explain every field in the database schema or every validation check. Focus on the happy-path data flow only.

Help me trim my interview walkthrough to fit in 2 minutes.

✔️ Awesome, I've got everything!

Great. Double check you have saved DESIGN.md with both the Interview Walkthrough at the top and the Scaling section at the bottom.

ⓧ I'd like to double check the full code

Here is the complete DESIGN.md file. Make sure yours matches:

# URL Shortener Design

## Interview Walkthrough

1. **Requirements (30s)**: We need to shorten URLs and redirect short codes to originals. Non-functionally: low latency redirects, ~100:1 read-to-write ratio, URL-safe codes.
2. **High-level design (30s)**: Client hits API server, which checks an in-memory cache before querying the database. Short codes are random 7-character base64url strings.
3. **Core components and data flow (60s)**: POST /api/shorten generates a code, stores it in SQLite, returns the short URL. GET /:code checks cache first (cache-aside), falls back to DB on miss, then 302 redirects.
4. **Trade-off**: I chose cache-aside because with a 100:1 read:write ratio, we only want to cache URLs that are actually accessed, not every URL on creation.

## Requirements

### Functional Requirements
- Shorten a long URL to a short code
- Redirect a short code to the original URL

### Non-Functional Requirements
- Low latency redirects
- High read-to-write ratio (~100:1)
- URL-safe short codes

## Trade-offs

### Why REST over GraphQL?
REST is simpler for this use case (2 endpoints). GraphQL adds complexity without benefit for a redirect-heavy service.

### Why SQL (SQLite) over NoSQL?
URL mappings are simple key-value pairs with a fixed schema. SQL gives us ACID transactions and indexing for fast lookups. At production scale, you might pick a key-value store like DynamoDB for horizontal scaling, but SQLite demonstrates the core concepts without infrastructure overhead.

### Why random codes vs. sequential/hash-based?
Random codes are unpredictable (security), but have collision risk. Hash-based (e.g., MD5 of URL) gives deterministic output but leaks information. Sequential (auto-increment encoded) is predictable. For a URL shortener, random with collision retry is the standard choice.

### Why cache-aside over write-through?
For a read-heavy service (100:1 ratio), cache-aside is ideal because we only cache URLs that are actually accessed. Write-through would cache every URL on creation, wasting memory on URLs that might never be visited.

### Why in-memory cache vs Redis?
In-memory works for single-server deployments. Redis is needed when you have multiple servers that need shared cache.

## Scaling

### Vertical Scaling
Add more RAM/CPU to handle more concurrent connections.

### Horizontal Scaling
Multiple API server instances behind a load balancer, shared Redis cache, read replicas for the database.

### Database Choice at Scale
Migrate from SQLite to PostgreSQL (for concurrent writes) or DynamoDB/Cassandra (for massive horizontal scale).

### CAP Theorem
For a URL shortener, availability matters more than strict consistency (AP system). A short URL being available 1 second late is acceptable; being unavailable is not.

### What I Would Change at 10x Scale
- Add a CDN for popular redirects
- Use a distributed cache (Redis Cluster)
- Switch to a key-value store
- Add monitoring

You now have a complete interview artifact. A working API, a visual diagram, documented trade-offs, and a rehearsed narrative. Next up, you'll push your design skills further with a secret mission.

Secret mission

Implement a Rate Limiter

Your URL shortener works, but what happens if someone sends 1,000 requests per second? In this secret mission, you'll implement a token bucket rate limiter from scratch to protect your API from abuse.

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:

  • The url-shortener project folder on your Desktop (contains all source files, node_modules, and the urls.db SQLite database).
  • The Express server process running on port 3000.
  • Your Excalidraw architecture diagram (browser tab).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep testing your URL shortener.

  • Your server continues running on http://localhost:3000. You can keep creating and testing short URLs.
  • Your Excalidraw diagram stays in the browser tab. Bookmark it or export as PNG if you want to save it for interview prep.
  • The urls.db database persists all your short URL mappings between server restarts.

Pause - I'll come back to this later

Shut down the running server to free up the port. Your files and database stay intact for next time.

  • Switch to the terminal where the server is running and press Ctrl+C to stop it.
  • Your project folder, database, and DESIGN.md all remain on disk. When you come back, run node server.js from inside the url-shortener folder to start again.
  • Save your Excalidraw diagram by clicking the menu icon and choosing Export to disk to save a .excalidraw file you can reopen later.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your code, database, and design documentation.

  • Switch to the terminal where the server is running and press Ctrl+C to stop it.
  • Delete the entire project folder by running this command from your Desktop:

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

cd ~\Desktop
rmdir /s /q url-shortener
  • This removes all source files (server.js, routes.js, db.js, cache.js, utils.js, rateLimiter.js), DESIGN.md, the urls.db database, and node_modules.
  • Close the Excalidraw browser tab. If you did not export the diagram, it is gone.

Nice Work!

Nice Work!

You just built a production-style URL shortener from scratch and documented every architectural decision along the way. That combination of working code plus articulated trade-offs is exactly what interviewers look for in a system design round.

You've learned how to:

  • Build a working URL shortener API with REST endpoints, SQLite persistence, and a cache-aside caching layer that demonstrates read-heavy optimization.
  • Design and draw a system architecture diagram in Excalidraw with labeled components and data flow arrows, ready to whiteboard in an interview.
  • Document trade-off decisions for every architectural choice in DESIGN.md, including a structured 2-minute interview walkthrough.
  • Secret Mission: Implemented a token bucket rate limiter from scratch, demonstrating rate limiting patterns used in real production APIs.

Ready to quiz yourself?