Design and Build a Scalable URL Shortener

Design, build, and load test a URL shortener with Redis caching.

Introduction

30 Second Summary

Every time you click a short link in a social media post or a text message, a system somewhere receives that code, looks up the original destination, and redirects you in milliseconds. Behind that simple experience is a real architecture problem: how do you serve millions of redirects without your database buckling under the load?

In this project, you will design and build a URL shortener from scratch. You will diagram the architecture first, implement it with a database and caching layer, then load test it to prove your design decisions work with real numbers.

What You'll Build

You will paste a long URL into your running API, get back a short code, and watch your browser redirect when you visit that code. Then you will hammer it with 100 concurrent connections, watch latency spike, add a cache, and watch latency drop.

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

  • A working URL shortener API where you paste any URL, get back a unique short code, and visit that code to get redirected to the original destination.
  • A Mermaid architecture diagram living in your codebase that documents how data flows between your client, API, database, and cache.
  • Real load test results showing before-and-after metrics that prove adding a Redis caching layer dramatically improves response times under concurrent traffic.
  • Secret Mission: Add rate limiting to protect your system from abuse and see 429 responses when the limit is exceeded.

Are there any prerequisites?

This project assumes familiarity with TypeScript and NestJS basics (modules, controllers, services). You should also have Docker Desktop installed and running on your machine.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

This project is about designing a system from scratch, not just coding features. You'll diagram an architecture, build it, then prove your design decisions with real load test data.

Set Up the Project

Your URL shortener needs three things working together: a NestJS API server, a PostgreSQL database to store URLs, and a Redis cache for fast lookups.

In this step, you'll scaffold the NestJS project, spin up PostgreSQL and Redis using Docker Compose, and wire the app module to connect to both services.

In this step, get ready to:
  • Scaffold the NestJS project and install dependencies.
  • Create a Docker Compose file for PostgreSQL and Redis.
  • Configure the app to connect to both services and verify it starts.
Scaffold the NestJS project and install dependencies

The NestJS CLI generates a full project skeleton with a single command. You need it installed globally to scaffold new projects.

  • Check if the NestJS CLI is installed by running:
nest --version

✔️ I see a version number

Your NestJS CLI is ready. Continue to the next instruction below.

ⓧ Command not found

  • Install the NestJS CLI globally by running:
npm i -g @nestjs/cli

Once the install completes, re-run nest --version to confirm it prints a version number.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new NestJS project by running:
nest new url-shortener

What will the CLI ask me?

The CLI will prompt: "Which package manager would you love to use?" with options npm, yarn, and pnpm.

Select npm and press Enter. The CLI will then install all the base packages automatically.

  • Move into the project folder by running:
cd url-shortener
  • Install the packages for database, caching, and Redis by running:
npm install @nestjs/typeorm typeorm pg @nestjs/cache-manager cache-manager @keyv/redis keyv cacheable

You should see added followed by a package count in the output, confirming all dependencies are installed.

Install failing?

  • Make sure you ran the install from inside the url-shortener folder. Run pwd to confirm your current directory ends with url-shortener.
  • If you see permission errors on macOS, prefix the install command with sudo.

Help me debug my npm install issue in the url-shortener project.

Create Docker Compose and configure the app

PostgreSQL stores your shortened URLs permanently. Redis provides a fast in-memory cache so repeated lookups skip the database entirely.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the url-shortener folder on your Desktop, then click Open.
  • In VS Code's Explorer sidebar, click the New File icon next to the project name.
  • Name the file docker-compose.yml and press Enter.
  • Paste the following into docker-compose.yml:
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: shortener
      POSTGRES_PASSWORD: shortener
      POSTGRES_DB: shortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7
    ports:
      - "6379:6379"

volumes:
  pgdata:

What does this file do?

  • The postgres service runs PostgreSQL 16 with a database called shortener, exposed on port 5432.
  • The redis service runs Redis 7, exposed on port 6379.
  • The pgdata volume persists database data between container restarts.
  • Save docker-compose.yml.
  • Confirm the file exists by running this in your terminal:
ls docker-compose.yml

You should see docker-compose.yml printed in the output.

  • In VS Code's Explorer sidebar, expand the src folder and click app.module.ts to open it.
  • Select all the existing content (Cmd+A on macOS, Ctrl+A on Windows) and delete it.
  • Paste these imports at the top of the file:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheModule } from '@nestjs/cache-manager';
import { Keyv } from 'keyv';
import KeyvRedis from '@keyv/redis';
import { KeyvCacheableMemory } from 'cacheable';
import { UrlsModule } from './urls/urls.module';
  • Below the imports, paste the module configuration:
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'shortener',
      password: 'shortener',
      database: 'shortener',
      autoLoadEntities: true,
      synchronize: true,
    }),
    CacheModule.registerAsync({
      isGlobal: true,
      useFactory: async () => {
        return {
          stores: [
            new Keyv({
              store: new KeyvCacheableMemory({ ttl: 60000, lruSize: 5000 }),
            }),
            new KeyvRedis('redis://localhost:6379'),
          ],
        };
      },
    }),
    UrlsModule,
  ],
})
export class AppModule {}

What does this module configure?

  • TypeOrmModule.forRoot connects NestJS to your PostgreSQL container using the same credentials from docker-compose.yml.
  • CacheModule.registerAsync sets up a two-tier cache: an in-memory layer (KeyvCacheableMemory) for the fastest lookups and a Redis layer for shared persistence.
  • UrlsModule is the feature module you'll build in a later step. You're importing it now so you only touch this file once.

The UrlsModule import references a file that doesn't exist yet. You need a placeholder so NestJS can start without errors.

  • In VS Code's Explorer sidebar, right-click the src folder and select New Folder.
  • Name it urls and press Enter.
  • Right-click the new urls folder and select New File.
  • Name it urls.module.ts and press Enter.
  • Paste this placeholder into src/urls/urls.module.ts:
import { Module } from '@nestjs/common';

@Module({})
export class UrlsModule {}
  • Save both app.module.ts and urls.module.ts.
Start all services and verify

With the configuration in place, it's time to boot everything up and confirm NestJS can talk to both PostgreSQL and Redis.

  • Make sure Docker Desktop is running on your machine.
  • Start the PostgreSQL and Redis containers by running:
docker compose up -d
  • Verify the containers are healthy by running:
docker compose ps

You should see two services listed: postgres and redis, both with a status of running.

Containers not starting?

  • Confirm Docker Desktop is open and running. The whale icon in your system tray should show a green status.
  • If port 5432 or 6379 is already in use, stop the conflicting process or change the host port in docker-compose.yml.

Help me debug my Docker Compose startup issue.

Before you start the NestJS dev server, what do you think will happen? Will the app start successfully, or will it fail to connect?

  • Start the NestJS development server by running:
npm run start:dev

You should see Nest application successfully started in the terminal output. This confirms NestJS connected to both PostgreSQL and Redis without errors.

App failing to start?

  • If you see a connection refused error mentioning port 5432 or 6379, your Docker containers may not be running. Run docker compose ps to check.
  • If you see a module resolution error for ./urls/urls.module, make sure src/urls/urls.module.ts exists and is saved.
  • If you see missing package errors, re-run the npm install command from the first substep.

Help me debug my NestJS startup failure.

✔️ Awesome, I've got everything!

Great. Double check that you've saved all files and that your terminal still shows the app running.

ⓧ I'd like to double check the full code

Here is the full docker-compose.yml:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: shortener
      POSTGRES_PASSWORD: shortener
      POSTGRES_DB: shortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7
    ports:
      - "6379:6379"

volumes:
  pgdata:

Here is the full src/app.module.ts. Scroll up to confirm both the imports section and the @Module configuration match what you have:

  • 7 import lines at the top (Module, TypeOrmModule, CacheModule, Keyv, KeyvRedis, KeyvCacheableMemory, UrlsModule).
  • TypeOrmModule.forRoot block connecting to PostgreSQL on port 5432.
  • CacheModule.registerAsync block with a stores array containing KeyvCacheableMemory and KeyvRedis.
  • UrlsModule at the end of the imports array.

Here is the full src/urls/urls.module.ts:

import { Module } from '@nestjs/common';

@Module({})
export class UrlsModule {}

Your environment is ready. You have a running NestJS server connected to PostgreSQL and Redis. Next up, you'll design the architecture that maps out how data flows through this system.

Design the Architecture

Your services are running and your NestJS app connects to both PostgreSQL and Redis. Now it's time to step back and think about how data actually flows through the system before writing any feature code.

This step is where you put on the architect hat. You'll diagram the system components, define the API contract, and specify the database schema. This is the skill that separates engineers who build features from engineers who design systems.

In this step, get ready to:
  • Create a Mermaid architecture diagram showing how data flows through the system.
  • Document the API contract for both endpoints.
  • Define the database schema for storing shortened URLs.
Create the architecture diagram

Architecture diagrams show how data moves between system components. You'll write yours in Mermaid, a text-based diagramming language that lives directly in your codebase as markdown.

  • Create a docs directory in your project by running this command:
mkdir docs
  • In VS Code, right-click the new docs folder in the Explorer sidebar and select New File.
  • Name the file architecture.md.
  • Add the following Mermaid flowchart diagram to the file:
# URL Shortener Architecture

## System Diagram

```mermaid
flowchart TD
    Client[Client] -->|POST /urls| API[NestJS API]
    Client -->|GET /:code| API
    API -->|Write new URL| DB[(PostgreSQL)]
    API -->|Check cache first| Cache[(Redis)]
    Cache -->|Cache miss| DB
    DB -->|Return URL| API
    Cache -->|Cache hit| API
    API -->|302 Redirect| Client
```

What does this diagram show?

The flowchart TD directive creates a top-down flowchart. Each node represents a system component: the client making requests, the NestJS API processing them, PostgreSQL storing URLs, and Redis caching lookups.

The edge labels (POST /urls, GET /:code, Cache miss) describe what triggers each data flow. The [( )] syntax renders database-shaped nodes.

Why Mermaid instead of a drawing tool?

Mermaid diagrams are plain text that lives in your codebase. They're version-controllable, diff-able, and render anywhere that supports markdown (GitHub, GitLab, VS Code).

Drawing tools like Excalidraw produce images that drift out of sync with the code. Text-based diagrams evolve alongside the system they document.

  • Save docs/architecture.md.

Before you preview the diagram, what do you expect it to look like? How many distinct boxes and connections do you think you'll see?

  • Open the Mermaid Live Editor in your browser.
  • Delete the example code in the left panel and paste only the content between the ```mermaid fences (starting from flowchart TD down to the last Client line).

You should see four connected components rendered as a diagram: Client, NestJS API, PostgreSQL (database shape), and Redis (database shape), with labeled arrows showing the data flow for both shorten and redirect paths.

Diagram not rendering?

Make sure you only pasted the mermaid syntax (starting with flowchart TD), not the surrounding ```mermaid fences. The Live Editor expects raw Mermaid code without markdown code fences around it.

Check for any missing characters in the edge labels. Each label needs the |text| pipe syntax on both sides.

help me debug my Mermaid diagram syntax

Document the API contract

The API contract defines exactly what your endpoints accept and return. Anyone reading this file knows how to interact with your service without reading the implementation.

  • In docs/architecture.md, add the following API contract section below the System Diagram:
## API Contract

### POST /urls
- Request: `{ "originalUrl": "https://example.com/very-long-path" }`
- Response: `{ "shortCode": "a1b2c3", "shortUrl": "http://localhost:3000/a1b2c3" }`

### GET /:code
- Response: 302 Redirect to the original URL
- Error: 404 if code not found

What do these endpoints do?

POST /urls accepts a long URL and returns a 6-character short code plus the full short URL. This is the "shorten" path in your diagram.

GET /:code takes a short code from the URL path and responds with a 302 redirect to the original URL. This is the "redirect" path. A 404 means the code doesn't exist in the database.

  • Save docs/architecture.md.
Define the database schema

The schema defines what gets stored and how. You'll also add placeholder sections for load test results that you'll fill in during later steps.

  • In docs/architecture.md, add the database schema and load test results sections below the API Contract:
## Database Schema

| Column      | Type        | Constraints          |
|-------------|-------------|----------------------|
| id          | integer     | Primary key, auto    |
| shortCode   | varchar(6)  | Unique index         |
| originalUrl | text        |                      |
| createdAt   | timestamp   | Default: now()       |

## Load Test Results

### Before Caching
- (Record your baseline metrics here)

### After Caching
- (Record your improved metrics here)

What does this schema define?

The urls table has four columns. The shortCode column has a unique index so lookups by short code are fast and no two URLs can share a code.

The Load Test Results sections are empty placeholders. You'll record real performance numbers there after running autocannon in Steps 4 and 5.

  • Save docs/architecture.md.

✔️ Awesome, I've got everything!

Great. Double check you've saved docs/architecture.md and move on to the next step.

ⓧ I'd like to double check the full code

Your docs/architecture.md file should contain these sections in order:

  • Heading: # URL Shortener Architecture.
  • System Diagram section with the full Mermaid flowchart (Client, API, DB, Cache nodes and labeled edges).
  • API Contract section with POST /urls and GET /:code documented.
  • Database Schema section with the 4-column table (id, shortCode, originalUrl, createdAt).
  • Load Test Results section with Before Caching and After Caching placeholders.

Scroll up and confirm each chunk matches what you pasted. All sections should be present in this order with no extra content between them.

Your architecture is documented. You have a visual diagram, a clear API contract, and a schema ready to implement. Next up, you'll turn this design into working code by building the naive URL shortener that hits the database on every request.

Build the Naive URL Shortener

You have a running database, a caching layer, and an architecture document describing exactly what to build. Now it's time to write the actual URL shortener.

This version is intentionally straightforward. Every redirect request queries PostgreSQL directly. No caching, no optimization. You need this naive baseline so you can feel the performance wall when you load test it in the next step.

In this step, get ready to:
  • Create the Url entity matching your database schema.
  • Build a service with shorten and resolve methods.
  • Wire up the controller with POST and GET endpoints and verify the redirect works.
Create the Url entity and wire it into the module

In NestJS with TypeORM, an entity is a TypeScript class that maps directly to a database table. Each property decorated with @Column() becomes a column. Your architecture doc already defines the schema, so you just need to translate it into code.

  • Create a new file at src/urls/url.entity.ts and add the following entity definition:
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm';

@Entity('urls')
export class Url {
  @PrimaryGeneratedColumn()
  id: number;

  @Index({ unique: true })
  @Column({ length: 6 })
  shortCode: string;

  @Column()
  originalUrl: string;

  @CreateDateColumn()
  createdAt: Date;
}

What does this code do?

This class maps to a urls table in PostgreSQL. Each instance of Url represents one row.

  • @PrimaryGeneratedColumn() creates an auto-incrementing integer primary key.
  • @Index({ unique: true }) on shortCode ensures no two URLs share the same 6-character code. The database enforces this constraint.
  • @CreateDateColumn() automatically sets createdAt to the current timestamp when a row is inserted.
  • Save url.entity.ts.
  • Confirm the file exists by running:
ls src/urls/

You should see url.entity.ts listed in the output.

Now the entity exists, but NestJS doesn't know about it yet. You need a module to register it with TypeORM and wire everything together.

  • Create a new file at src/urls/urls.module.ts and add the following module definition:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Url } from './url.entity';
import { UrlsService } from './urls.service';
import { UrlsController } from './urls.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Url])],
  providers: [UrlsService],
  controllers: [UrlsController],
})
export class UrlsModule {}

What does this code do?

This module is the glue that connects your entity, service, and controller into a cohesive feature.

  • TypeOrmModule.forFeature([Url]) registers the Url entity so TypeORM creates a repository for it. This is what lets you query the urls table.
  • providers lists services that contain business logic. controllers lists classes that handle HTTP requests.

Your app.module.ts already imports UrlsModule. The dev server will show a compilation error right now because urls.service.ts and urls.controller.ts don't exist yet. You'll create them next.

  • Save urls.module.ts.
  • Run ls src/urls/ again. You should now see both url.entity.ts and urls.module.ts listed.

Don't see the urls folder?

Make sure you created the files inside src/urls/, not directly in src/. The folder should be a subdirectory of your project's src folder.

help me find where to create files in my NestJS project structure

Build the UrlsService with shorten and resolve methods

The service contains the business logic. It knows how to generate a short code and how to look up the original URL from the database. The controller will call these methods to handle incoming requests.

  • Create a new file at src/urls/urls.service.ts and add the following service:
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Url } from './url.entity';
import { randomBytes } from 'crypto';

@Injectable()
export class UrlsService {
  constructor(
    @InjectRepository(Url)
    private urlsRepository: Repository<Url>,
  ) {}

  async shorten(originalUrl: string): Promise<Url> {
    const shortCode = randomBytes(3).toString('hex');
    const url = this.urlsRepository.create({ shortCode, originalUrl });
    return this.urlsRepository.save(url);
  }

  async resolve(shortCode: string): Promise<string> {
    const url = await this.urlsRepository.findOneBy({ shortCode });
    if (!url) {
      throw new NotFoundException('Short URL not found');
    }
    return url.originalUrl;
  }
}

What does this code do?

Two methods handle the core logic:

  • shorten() generates a random 6-character hex string using Node's built-in crypto module. It creates a new Url row in PostgreSQL with that code and the original URL.
  • resolve() looks up a short code in the database. If it finds a match, it returns the original URL. If not, it throws a NotFoundException which NestJS automatically converts to a 404 response.

Notice that resolve() hits PostgreSQL on every single call. There's no caching. Every redirect request makes a database query. This is the deliberate bottleneck you'll measure later.

  • Save urls.service.ts.
  • Check your terminal running npm run start:dev. You'll see a compilation error mentioning the missing controller file. This is expected because urls.module.ts references UrlsController which doesn't exist yet.

Seeing a different error?

If the error mentions url.entity or urls.module rather than the controller, double-check that all three files are saved and that their import paths match the filenames exactly. TypeScript imports are case-sensitive.

help me debug a NestJS compilation error in my URL shortener module

Build the UrlsController and test the redirect

The controller is the HTTP layer. It receives requests, delegates to the service, and formats the response. Once this file exists, all four files in src/urls/ are in place and the app will compile.

  • Create a new file at src/urls/urls.controller.ts and add the following controller:
import { Controller, Post, Get, Body, Param, Res } from '@nestjs/common';
import { Response } from 'express';
import { UrlsService } from './urls.service';

@Controller()
export class UrlsController {
  constructor(private readonly urlsService: UrlsService) {}

  @Post('urls')
  async shorten(@Body('originalUrl') originalUrl: string) {
    const url = await this.urlsService.shorten(originalUrl);
    return {
      shortCode: url.shortCode,
      shortUrl: `http://localhost:3000/${url.shortCode}`,
    };
  }

  @Get(':code')
  async redirect(@Param('code') code: string, @Res() res: Response) {
    const originalUrl = await this.urlsService.resolve(code);
    return res.redirect(302, originalUrl);
  }
}

What does this code do?

Two endpoints match your API contract from the architecture doc:

  • @Post('urls') accepts a JSON body with originalUrl, calls shorten(), and returns the generated short code plus the full short URL.
  • @Get(':code') captures any path parameter, calls resolve() to look up the original URL, and returns a 302 redirect. The browser or HTTP client follows this redirect automatically.

The @Res() decorator gives you direct access to the Express response object, which you need to issue the 302 redirect manually.

  • Save urls.controller.ts.
  • Check your terminal. The dev server should recompile successfully and display a message containing Nest application successfully started.

App not starting?

If you still see a compilation error, verify that all four files exist inside src/urls/:

ls src/urls/

You should see: url.entity.ts, urls.controller.ts, urls.module.ts, and urls.service.ts. If any file is missing, create it using the code above.

help me fix NestJS compilation errors in my URL shortener project

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved and your terminal shows the app running.

ⓧ I'd like to double check the full code

Scroll up and compare each of your files against the code blocks above. Confirm you have these four files in order:

  • src/urls/url.entity.ts with the Url class and its four decorated properties.
  • src/urls/urls.module.ts importing TypeOrmModule.forFeature([Url]) and declaring the service and controller.
  • src/urls/urls.service.ts with shorten() and resolve() methods.
  • src/urls/urls.controller.ts with @Post('urls') and @Get(':code') endpoints.

Time to test your URL shortener end-to-end. Before you run the next command, think about this: when you POST a URL to your API, what shape of JSON response do you expect back?

  • Shorten a URL by running this command:
curl -X POST http://localhost:3000/urls -H "Content-Type: application/json" -d '{"originalUrl": "https://www.example.com/very-long-path"}'

You should see a JSON response containing a shortCode (a random 6-character hex string) and a shortUrl like http://localhost:3000/a1b2c3.

  • Copy the short code from the response. Now think: what HTTP status code does a redirect return? Test the redirect by running this command (replace the code with yours):
curl -I http://localhost:3000/[[SHORTCODE="your 6-character code from the POST response"]]

You should see a 302 status with a Location header pointing to https://www.example.com/very-long-path. That's your URL shortener working end-to-end.

Getting a 404 or connection refused?

  • If you see Connection refused, make sure npm run start:dev is running and the terminal shows the app started successfully.
  • If you see a 404 on the POST, check that you included the Content-Type: application/json header. Without it, NestJS can't parse the JSON body.
  • If the POST works but the GET returns 404, double-check that you're using the exact short code from the POST response, not a placeholder.

help me debug my NestJS URL shortener curl requests

Your URL shortener works. You can create short URLs and they redirect correctly. But how does it perform under pressure? Next up, you'll throw 100 concurrent connections at it and watch what happens to the response times.

Load Test and Hit the Wall

Your URL shortener works. You can POST a link, get back a short code, and follow it to a redirect. But you have no idea how it performs when 100 users hit it at the same time.

In this step, you'll blast the redirect endpoint with 100 concurrent connections using autocannon, a Node.js-native load testing tool. The results will reveal a clear bottleneck: every single redirect queries PostgreSQL directly, and latency climbs fast under concurrent traffic.

In this step, get ready to:
  • Install autocannon and seed the database with a test URL.
  • Run a load test with 100 concurrent connections against the redirect endpoint.
  • Record baseline metrics in your architecture documentation.
Install autocannon and seed a test URL

Before you can load test, you need two things: the testing tool itself and a URL already saved in your database to hit repeatedly.

  • Install autocannon globally by running this command:
npm i autocannon -g

What is autocannon?

Autocannon is a fast HTTP benchmarking tool written in Node.js. It fires concurrent requests at a URL and measures how fast (or slow) your server responds. Because it installs via npm, you stay inside the Node.js ecosystem without needing a separate binary.

  • Confirm the install worked by running:
autocannon --version

You should see a version number printed (e.g. 8.0.0 or higher).

Seeing command not found?

Make sure you used the -g flag to install globally. If your npm global bin directory is not on your PATH, try closing and reopening your terminal.

help me fix autocannon not being found after npm global install

Now you need a short URL in your database to test against. Your NestJS dev server should still be running from the previous step.

  • Seed a test URL by running this curl command:
curl -X POST http://localhost:3000/urls \
  -H "Content-Type: application/json" \
  -d '{"originalUrl": "https://example.com/very-long-path"}'

You should see a JSON response containing a shortCode and a shortUrl. Copy the 6-character shortCode value from the response. You'll use it in the next substep.

What does this response look like?

The response looks something like {"shortCode":"a1b2c3","shortUrl":"http://localhost:3000/a1b2c3"}. Your shortCode will be a random 6-character hex string. This is the code you'll load test.

Getting connection refused?

Make sure your NestJS dev server is still running (npm run start:dev in a separate terminal) and that Docker Compose containers are healthy (docker compose ps).

help me debug connection refused when curling localhost:3000

Run the load test

You have a working redirect endpoint and autocannon installed. Time to find out what happens when traffic gets real.

Before you run this, think for a moment: your resolve() method queries PostgreSQL on every single request. What do you think will happen to response times when 100 connections hammer it simultaneously for 10 seconds?

  • Run the load test by pasting this command, replacing the short code with your own:
autocannon -c 100 -d 10 http://localhost:3000/[[SHORTCODE="your-short-code"]]

What do these flags mean?

-c 100 opens 100 concurrent connections to your server. Each connection sends a new request the moment it gets a response back.

-d 10 runs the test for 10 seconds. This gives enough samples for meaningful percentile statistics.

The test takes about 10 seconds. When it finishes, autocannon prints two tables and a summary line.

Look at the output. The first table shows latency at different percentiles (2.5%, 50%, 97.5%, 99%). The second table shows requests per second. At the bottom you'll see a summary like "X requests in 10s, Y MB read".

How to read the output

The Latency row shows how long each request took. Lower is better. The Avg column is the number you'll compare later.

The Req/Sec row shows how many requests the server completed each second. Higher is better. The Avg column here is your throughput baseline.

Notice how the 97.5% and 99% latency values are significantly higher than the average. That tail latency is where your users feel pain. Every one of those requests waited for a PostgreSQL connection from the pool.

Why is latency higher than you might expect?

Every single one of those 100 concurrent connections runs a SQL query against PostgreSQL. The database connection pool has a limited number of slots. When all slots are busy, new queries queue up and wait. That queuing time is exactly what you're seeing in the latency numbers.

This is the wall. Your shortener works, but it doesn't scale. The next step fixes this by adding a Redis caching layer so most requests never touch the database at all.

Record the baseline metrics

These numbers are your "before" snapshot. You'll compare them against the cached version in the next step. Recording them in your architecture doc makes the improvement provable.

  • In docs/architecture.md, find the ### Before Caching section.
  • Find this line:
- (Record your baseline metrics here)
  • Replace it with two lines recording the Avg latency and Avg Req/Sec values from your autocannon output:
- Average latency: [[BASELINE_LATENCY="your Avg latency value, e.g. 12.5 ms"]]
- Requests/sec: [[BASELINE_RPS="your Avg Req/Sec value, e.g. 4500"]]

What values should I write?

Copy the exact numbers from the Avg column in your autocannon output. The Latency row's Avg is your average response time. The Req/Sec row's Avg is your throughput. Your specific numbers will depend on your machine's hardware.

  • Save docs/architecture.md.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file with your actual numbers filled in.

ⓧ I'd like to double check the full code

Your docs/architecture.md is a long file. Scroll through and confirm these sections are present in order:

  • The # URL Shortener Architecture heading at the top.
  • The ## System Diagram section with your Mermaid flowchart.
  • The ## API Contract section with POST and GET endpoints documented.
  • The ## Database Schema table.
  • The ## Load Test Results section with your ### Before Caching subsection now containing your actual latency and requests/sec values.
  • The ### After Caching subsection still has its placeholder (you'll fill this in during the next step).

You've exposed the bottleneck. Every redirect queries PostgreSQL, and under 100 concurrent connections the database connection pool becomes the chokepoint. Next up, you'll add a Redis caching layer and watch those numbers transform.

Add Redis Caching and Prove the Improvement

In the last step, you load tested your shortener and watched latency climb under 100 concurrent connections. Every redirect query hits PostgreSQL directly, and that's the bottleneck your numbers revealed.

The fix is a Redis caching layer. Redis serves cached responses in microseconds instead of the milliseconds PostgreSQL needs. Your app.module.ts already configures the CacheModule with a Redis backing store. Now you'll wire it into UrlsService and prove the improvement with real numbers.

In this step, get ready to:
  • Update resolve() to check Redis before hitting PostgreSQL.
  • Re-run the load test and compare before/after metrics.
  • Document the improvement in your architecture doc.
Update the imports and constructor to inject the cache manager

The NestJS CacheModule exposes a CACHE_MANAGER token you can inject into any service. Once injected, you get a Cache instance with get() and set() methods backed by your Redis store.

  • In src/urls/urls.service.ts, replace the entire file contents with the following imports and constructor setup:
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from '@nestjs/cache-manager';
import { Url } from './url.entity';
import { randomBytes } from 'crypto';

@Injectable()
export class UrlsService {
  constructor(
    @InjectRepository(Url)
    private urlsRepository: Repository<Url>,
    // Inject the global cache manager backed by Redis
    @Inject(CACHE_MANAGER)
    private cacheManager: Cache,
  ) {}
}

What changed here?

  • You added Inject to the @nestjs/common import line.
  • Two new imports bring in CACHE_MANAGER (the injection token) and Cache (the type) from @nestjs/cache-manager.
  • The constructor now receives cacheManager alongside the existing repository. This gives your service direct access to the Redis-backed cache.
Add the cache-first resolve method

The cache-first pattern works like this: check Redis, return immediately on a hit, query PostgreSQL only on a miss, then store the result in Redis for next time.

  • Below the constructor's closing brace in urls.service.ts, add the shorten() and updated resolve() methods:
  // Generate a random 6-char code and save to PostgreSQL
  async shorten(originalUrl: string): Promise<Url> {
    const shortCode = randomBytes(3).toString('hex');
    const url = this.urlsRepository.create({ shortCode, originalUrl });
    return this.urlsRepository.save(url);
  }

  // Cache-first resolution: check Redis, fall back to DB
  async resolve(shortCode: string): Promise<string> {
    // Try Redis first
    const cached = await this.cacheManager.get<string>(`url:${shortCode}`);
    if (cached) {
      return cached;
    }

    // Cache miss: query PostgreSQL
    const url = await this.urlsRepository.findOneBy({ shortCode });
    if (!url) {
      throw new NotFoundException('Short URL not found');
    }

    // Store in Redis with 60-second TTL for next time
    await this.cacheManager.set(`url:${shortCode}`, url.originalUrl, 60000);
    return url.originalUrl;
  }

What does this code do?

The shorten() method is unchanged from before. The magic is in resolve():

  • cacheManager.get('url:{shortCode}') checks Redis for a cached original URL. If found, it returns instantly without touching PostgreSQL.
  • On a cache miss, the method queries the database as before.
  • cacheManager.set() stores the result in Redis with a 60-second TTL (60000 milliseconds). Subsequent requests for the same code skip the database entirely until the cache expires.
  • Save src/urls/urls.service.ts. NestJS auto-reloads the dev server.
  • Confirm the redirect still works by running this curl against your test short code:
curl -I http://localhost:3000/[[TESTCODE="your test short code from Step 4"]]

You should see a 302 response with a Location header pointing to your original URL. The behavior is identical to before. The difference is invisible from a single request.

Seeing a 500 error or compile failure?

  • Check that you have the closing } for the class at the very end of the file. The constructor block, shorten(), and resolve() must all sit inside the class body.
  • Make sure both CACHE_MANAGER and Cache are imported from @nestjs/cache-manager. A missing import causes a runtime injection error.
  • Confirm Docker containers are still running. Redis must be reachable at localhost:6379.

Help me debug a 500 error after adding CACHE_MANAGER injection to my NestJS service.

✔️ Awesome, I've got everything!

Great. Make sure you've saved urls.service.ts and confirmed the redirect still returns a 302.

ⓧ I'd like to double check the full code

Scroll up and confirm your src/urls/urls.service.ts contains these sections in order:

  • Imports: Injectable, NotFoundException, Inject from @nestjs/common. Then CACHE_MANAGER and Cache from @nestjs/cache-manager. Then Url entity and randomBytes.
  • Constructor with urlsRepository and cacheManager injected.
  • shorten() method generating a random code and saving to the database.
  • resolve() method checking Redis first, querying PostgreSQL on miss, then storing result with 60000 ms TTL.
Re-run the load test and compare results

Your caching layer is live. Now you'll hit it with the same load test as before and see whether the numbers actually improve.

Before you run this, what do you predict will happen to latency and requests/sec compared to your baseline?

  • Run the same autocannon command you used in Step 4:
autocannon -c 100 -d 10 http://localhost:3000/[[TESTCODE="your test short code from Step 4"]]

You should see significantly lower average latency and higher requests/sec compared to your "Before Caching" numbers. The first request warms the cache (a miss that hits PostgreSQL). Every subsequent request for the same code is served from Redis.

Why is the improvement so dramatic?

PostgreSQL handles each query by parsing SQL, planning execution, reading from disk (or its own buffer pool), and returning results over a TCP socket. Redis stores the value in memory with a simple key lookup. Under 100 concurrent connections, that difference compounds into a massive latency reduction.

Numbers look the same as before?

  • Make sure you saved urls.service.ts and the NestJS dev server reloaded (check the terminal for the restart log).
  • Confirm Redis is running with docker compose ps. The redis container should show as healthy.
  • Verify you're testing the same short code that exists in the database.

Help me figure out why my Redis caching isn't improving load test results.

Document the after-caching metrics in your architecture doc

You now have hard numbers proving your caching layer works. Record them alongside your baseline so you can reference the comparison later.

  • Open docs/architecture.md and find the ### After Caching section.
  • Replace the placeholder line with your actual metrics from the autocannon output:
### After Caching
- Average latency: (recorded from autocannon output)
- Requests/sec: (recorded from autocannon output)

What does this code do?

You're filling in the same metric format as your baseline. Having both sections side by side makes the improvement immediately visible to anyone reading your architecture documentation.

  • Scroll up to your Mermaid diagram in the same file. Confirm it already shows the cache-first flow: Client connects to API, which checks Cache first and falls back to DB on a miss.
  • Save docs/architecture.md.

Your diagram already documents the cache layer

When you designed the architecture in Step 2, the Mermaid diagram included the Redis cache path as a forward-looking design decision. Now that the implementation matches, your diagram is both a design artifact and accurate system documentation.

Diagram missing the cache path?

If your diagram only shows the direct API → DB path, add the Cache node with edges for cache hit and cache miss. The Complete Code version of the diagram in Step 2 shows the correct structure.

Help me update my Mermaid diagram to show the Redis cache-first flow.

Secret mission

Add Rate Limiting

Your URL shortener is fast, but it's wide open. Anyone can hammer your POST endpoint and fill your database with junk entries. In this secret mission, you'll add rate limiting to protect your system from abuse.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally via Docker, so there are no ongoing costs.

Resources you used:

  • Docker containers (PostgreSQL and Redis) with a persistent data volume.
  • Your NestJS project folder on your Desktop (source code, docs/architecture.md, node_modules).
  • Global npm packages: @nestjs/cli and autocannon.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your URL shortener.

  • Your Docker containers will keep running in the background until you stop Docker Desktop or restart your machine.
  • Your NestJS dev server keeps running as long as the terminal is open.
  • The global @nestjs/cli and autocannon packages remain available for future projects.

Pause - I'll come back to this later

Stop running processes to free up memory, but keep all your files and data intact for when you return.

  • Stop the NestJS dev server by pressing Ctrl+C in the terminal where it's running.
  • Stop your Docker containers (without deleting data) by running:
docker compose stop
  • When you're ready to resume, run docker compose start from your project folder, then npm run start:dev.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your containers, data volumes, project files, and global packages.

  • Stop the NestJS dev server by pressing Ctrl+C in the terminal where it's running.
  • Remove the Docker containers and their data volumes by running:
docker compose down -v
  • Delete your project folder from the Desktop by running:
cd ~/Desktop
rm -rf url-shortener

Windows users

If you're on Windows, use this command instead to delete your project folder:

cd ~\Desktop
rmdir /s /q url-shortener
  • Uninstall the global npm packages by running:
npm uninstall -g @nestjs/cli autocannon

Nice Work!

Nice Work!

Nice work! You just designed and built a production-style URL shortener from the ground up, complete with architecture documentation and load test data proving your design decisions.

Here's what you learned:

  • Designed a system architecture from scratch using Mermaid diagrams and API contracts, then built the multi-service application (NestJS + PostgreSQL + Redis) orchestrated with Docker Compose.
  • Identified a performance bottleneck through load testing with autocannon, then solved it by adding a Redis caching layer and proved the improvement with before/after metrics.
  • Created a documented architecture with load test data backing every design decision, the same artifact senior engineers produce in real system design reviews.
  • Protected the system from abuse by adding rate limiting with @nestjs/throttler and verified it under load (Secret Mission).

Ready to quiz yourself?