Build and Break a URL Shortener API

Build a URL shortener API, then load test it until it breaks.

Introduction

30 Second Summary

Ever wonder why websites crash when too many people visit at once? Behind every crashed site is a server that hit its breaking point.

In this project, you will build a URL shortener API from scratch, then bombard it with hundreds of simultaneous users using a professional load testing tool. You will find the exact moment your server breaks under pressure.

What You'll Build

A fully functional URL shortener API (like a mini bit.ly) with database persistence, paired with an automated load testing setup that simulates real user traffic.

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

  • A working REST API with endpoints that create short URLs, redirect visitors, and track statistics.
  • A SQLite database that persists your data across server restarts.
  • Load test results showing your API's exact breaking point under concurrent traffic.
  • Secret Mission: Add an in-memory cache layer and measure the real performance improvement.

Are there any prerequisites?

No prior experience with APIs or databases is needed. This guide walks you through setting up all required tools from scratch.

Before We Start

Before We Start...

Set Up Your Environment

Before you write any API code, you need a clean workspace with the right tools installed.

In this step, you'll confirm your Python version, create an isolated project folder, and install the two frameworks that power this project: FastAPI (for building the API) and Locust (for load testing it).

In this step, get ready to:
  • Verify your Python version and create a project directory with a virtual environment.
  • Install FastAPI and Locust using pip.
  • Verify both installations work.
Verify your Python version and create your project directory

This project requires Python 3.10 or higher. Let's confirm you have the right version.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open a terminal inside Cursor by pressing Ctrl+` (the backtick key, usually above Tab).
  • Check your Python version by running this command:
python3 --version

Windows users

If python3 doesn't work, try python --version instead. On Windows, the command is often just python.

✔️ I see version 3.10 or higher

You're all set. Move on to creating your project directory below.

ⓧ I see an older version

Your system has Python installed, but it's an older version that won't work with the packages in this project.

  • Download Python 3.10 or newer from python.org/downloads.
  • Run the installer and check the box that says Add Python to PATH.
  • Close and reopen your terminal, then run the version check again.

macOS users with an older system Python

macOS often ships with an older Python version as the default. After installing a newer version, you may need to use python3.10 or python3.11 (the specific version binary) instead of python3 until you configure your PATH.

ⓧ Command not found

Python isn't installed on your machine yet.

  • Download Python 3.10 or newer from python.org/downloads.
  • Run the installer and check the box that says Add Python to PATH.
  • Close and reopen your terminal, then run python3 --version again to confirm it shows 3.10 or higher.
  • 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
  • Confirm the folder was created by running:
ls

The output should be empty since the folder is brand new. That's expected.

  • Create a virtual environment inside your project folder by running:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated space where Python packages are installed separately from your system-wide Python. This prevents conflicts between projects that need different versions of the same package.

The venv folder it creates holds a private copy of pip and any packages you install from here on.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should now see (venv) at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

  • Make sure you ran the activation command from inside the url-shortener folder (where the venv directory lives).
  • On Windows, if you see an execution policy error in PowerShell, run venv\Scripts\activate.bat in Command Prompt instead.

Help me activate my virtual environment

Install FastAPI and Locust

With your virtual environment active, every package you install stays contained inside this project.

  • Install both FastAPI (with its standard dependencies) and Locust by running:
pip install "fastapi[standard]" locust

What are these packages?

fastapi[standard] installs FastAPI along with a development server (Uvicorn) and tools for auto-generated interactive API docs. You'll use these in the next step.

locust is a load testing tool that simulates many users hitting your API at once. It comes with a real-time web dashboard that shows performance charts as the test runs.

The install may take 30-60 seconds. Wait until you see your terminal prompt return.

Seeing install errors?

  • Confirm your virtual environment is active. You should see (venv) in your prompt before running pip install.
  • If you see a permissions error, do NOT use sudo. Instead, deactivate and re-activate your virtual environment, then try again.
  • If a specific package fails to build, make sure your Python version is 3.10 or higher (both FastAPI and Locust require it).

Help me fix my pip install error

Verify both installations
  • Verify FastAPI installed correctly by running:
fastapi --help

You should see a list of available commands including dev and run.

  • Verify Locust installed correctly by running:
locust -V

You should see a version number printed in the terminal.

✔️ Both commands show output

Your environment is fully set up. Both tools are ready to use.

ⓧ One or both say command not found

  • Check that your virtual environment is still active (look for (venv) in your prompt).
  • If it's not active, re-activate it and try the commands again.
  • If the environment is active but the command still fails, re-run the install:
pip install "fastapi[standard]" locust

Help me fix my FastAPI or Locust installation

Your environment is ready. Next up, you'll build the URL shortener API itself using FastAPI.

Build the URL Shortener API

Your environment is ready with FastAPI and Locust installed. Now it's time to build the actual API that shortens URLs, redirects visitors, and tracks what's been created.

Every URL shortener (like bit.ly) works the same way: you give it a long URL, it hands back a short code, and anyone who visits that short code gets redirected to the original. You'll build this entire flow from scratch in a single Python file.

In this step, get ready to:
  • Create a FastAPI application with Pydantic data models and a POST endpoint for shortening URLs.
  • Add redirect and listing endpoints with correct route ordering.
  • Run and test your API using FastAPI's auto-generated interactive docs.
Create your API file with the POST endpoint

Your API needs a foundation: imports, an app instance, data models that define what requests and responses look like, and storage to hold the URLs.

  • Create a new file called main.py in your url-shortener folder by running this command:
touch main.py
  • Confirm the file exists by running:
ls

You should see main.py listed in the output.

  • Open main.py in Cursor by clicking on it in the Explorer sidebar on the left.
  • Add the following code to set up your imports, app instance, in-memory storage, and data models:
import string
import random

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel

app = FastAPI(title="URL Shortener")

# In-memory storage
url_store: dict[str, str] = {}


# Pydantic models
class URLRequest(BaseModel):
    url: str


class URLResponse(BaseModel):
    short_code: str
    original_url: str
    short_url: str

What does this code do?

  • string and random are Python standard library modules you'll use to generate random short codes.
  • FastAPI, HTTPException, RedirectResponse, and BaseModel are the framework pieces that handle routing, errors, redirects, and request validation.
  • url_store is a simple Python dictionary that maps short codes to original URLs. It lives in memory for now.
  • URLRequest and URLResponse are Pydantic models that define the exact shape of data going in and out of your API.

You won't see a change yet. The next chunk adds the endpoint that puts these models to work.

Now you need a function to generate random short codes and your first endpoint that accepts a URL and returns a shortened version.

  • Add the following code below your URLResponse class in main.py:
# Helper function
def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


# Routes
@app.post("/shorten")
def shorten_url(request: URLRequest) -> URLResponse:
    short_code = generate_short_code()
    url_store[short_code] = request.url
    return URLResponse(
        short_code=short_code,
        original_url=request.url,
        short_url=f"http://localhost:8000/{short_code}",
    )

What does this code do?

  • generate_short_code() picks 6 random characters from letters and digits to create a unique short code like aB3xYz.
  • @app.post("/shorten") creates a REST endpoint that responds to POST requests at the /shorten path.
  • The function generates a short code, stores the mapping in url_store, and returns a structured response with the new short URL.
  • Save main.py.

Your POST endpoint is ready. You'll test it once the remaining routes are in place.

Add the redirect and listing endpoints

A URL shortener needs two more capabilities: listing all created URLs and redirecting visitors who use a short code. The order you declare these routes matters.

Why does route order matter?

FastAPI evaluates routes top-to-bottom. A catch-all path parameter like /{short_code} matches ANY path, including /api/urls.

By declaring /api/urls first, FastAPI checks it before falling through to the catch-all. If you reversed the order, visiting /api/urls would try to look up a short code called api and fail.

  • Add the following two endpoints below your shorten_url function in main.py:
@app.get("/api/urls")
def list_urls():
    return [
        {"short_code": k, "original_url": v} for k, v in url_store.items()
    ]


@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    if short_code not in url_store:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=url_store[short_code], status_code=307)

What does this code do?

  • GET /api/urls returns a list of all shortened URLs and their original targets. This is declared FIRST so it doesn't get swallowed by the catch-all below.
  • GET /{short_code} looks up the short code in url_store. If found, it returns a 307 redirect to the original URL. If not, it raises a 404 error.
  • A 307 redirect tells the browser to go to the original URL while preserving the HTTP method of the request.
  • Save main.py.

Your complete API is ready. Let's start the server next.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import string
import random

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel

app = FastAPI(title="URL Shortener")

# In-memory storage
url_store: dict[str, str] = {}


# Pydantic models
class URLRequest(BaseModel):
    url: str


class URLResponse(BaseModel):
    short_code: str
    original_url: str
    short_url: str


# Helper function
def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


# Routes
@app.post("/shorten")
def shorten_url(request: URLRequest) -> URLResponse:
    short_code = generate_short_code()
    url_store[short_code] = request.url
    return URLResponse(
        short_code=short_code,
        original_url=request.url,
        short_url=f"http://localhost:8000/{short_code}",
    )


@app.get("/api/urls")
def list_urls():
    return [
        {"short_code": k, "original_url": v} for k, v in url_store.items()
    ]


@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    if short_code not in url_store:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=url_store[short_code], status_code=307)
Run and test your API

FastAPI includes a built-in development server that auto-reloads when you change code. It also generates interactive documentation where you can test every endpoint directly in your browser.

  • Start the development server by running this command in your terminal:
fastapi dev main.py

You should see output indicating the server is running at http://127.0.0.1:8000 with auto-reload enabled.

Seeing an error about the app not being found?

  • Make sure you saved main.py and that the file is in the url-shortener directory where your terminal is.
  • Check that your virtual environment is still active. You should see (.venv) at the beginning of your terminal prompt.
  • help me debug my fastapi dev command

Now let's test your endpoints using the auto-generated docs.

  • Open your browser and go to http://127.0.0.1:8000/docs.

You should see an interactive page titled URL Shortener with all three of your endpoints listed.

What is this docs page?

FastAPI automatically generates a Swagger UI page from your Python type hints and Pydantic models. You can expand any endpoint, fill in test data, and hit Execute to send a real request to your running server.

  • Click on the POST /shorten endpoint to expand it.
  • Click Try it out.
  • Replace the request body with this JSON:
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
  • Click Execute.

You should see a response containing a short_code, the original_url, and a short_url like http://localhost:8000/aB3xYz.

  • Copy the short_url value from the response.
  • Paste it into a new browser tab and press Enter.

Your browser should redirect you to the original YouTube URL. That's your redirect endpoint working.

Not getting redirected?

  • Make sure you're pasting the full URL including http://localhost:8000/ followed by the short code.
  • Check that the FastAPI server is still running in your terminal. If it crashed, restart it with fastapi dev main.py.
  • help me debug my URL redirect

Your API can create short URLs and redirect visitors. Right now, everything is stored in a Python dictionary, which means all your URLs disappear the moment you stop the server. Next up, you'll add a database so your URLs survive restarts.

Add Database Persistence

Your URL shortener works, but it has a critical flaw. Every time you stop the server, all your shortened URLs vanish.

That is because the data lives in a Python dictionary, which only exists in memory while the program runs. In this step, you will swap that dictionary for a real database so your URLs survive restarts.

In this step, get ready to:
  • Replace the in-memory dictionary with a SQLite database.
  • Update all endpoints to read from and write to the database.
  • Add a new statistics endpoint and verify data persists across restarts.
Add SQLite database setup

Python ships with a built-in module called sqlite3 that gives you a full relational database stored in a single file. No extra installs needed.

  • In main.py, select everything from the first line down to (but not including) the # Pydantic models comment.
  • Delete that selection and replace it with the following code:
import sqlite3
import string
import random
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel


# Database setup
def get_db():
    conn = sqlite3.connect("urls.db")
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE NOT NULL,
            original_url TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            visit_count INTEGER DEFAULT 0
        )
    """)
    conn.commit()
    conn.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    init_db()
    yield


app = FastAPI(title="URL Shortener", lifespan=lifespan)

What does this code do?

  • get_db() opens a connection to a file called urls.db. If the file does not exist yet, SQLite creates it automatically.
  • init_db() creates the urls table with columns for an auto-incrementing ID, a unique short code, the original URL, a timestamp, and a visit counter.
  • The lifespan context manager runs init_db() once when the server starts up. This is the modern FastAPI approach for startup logic.
  • row_factory = sqlite3.Row lets you access columns by name (like a dictionary) instead of by index number.

You will not see a change yet. The next chunk updates your routes to use this database.

Update all endpoints to query the database

With the database layer in place, every endpoint needs to open a connection, run its query, then close the connection. You will also add a brand-new statistics endpoint.

  • In main.py, scroll down past the generate_short_code helper function.
  • Select the # Routes comment and all three route functions below it (shorten_url, list_urls, and redirect_to_url).
  • Delete that selection and replace it with the following code:
# Routes (specific paths BEFORE the catch-all path parameter)
@app.post("/shorten")
def shorten_url(request: URLRequest) -> URLResponse:
    short_code = generate_short_code()
    conn = get_db()
    try:
        conn.execute(
            "INSERT INTO urls (short_code, original_url) VALUES (?, ?)",
            (short_code, request.url),
        )
        conn.commit()
    except sqlite3.IntegrityError:
        short_code = generate_short_code(8)
        conn.execute(
            "INSERT INTO urls (short_code, original_url) VALUES (?, ?)",
            (short_code, request.url),
        )
        conn.commit()
    finally:
        conn.close()

    return URLResponse(
        short_code=short_code,
        original_url=request.url,
        short_url=f"http://localhost:8000/{short_code}",
    )


@app.get("/api/urls")
def list_urls():
    conn = get_db()
    rows = conn.execute(
        "SELECT short_code, original_url, visit_count, created_at FROM urls ORDER BY created_at DESC LIMIT 20"
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


@app.get("/api/stats")
def get_stats():
    conn = get_db()
    total_urls = conn.execute("SELECT COUNT(*) FROM urls").fetchone()[0]
    total_visits = conn.execute(
        "SELECT COALESCE(SUM(visit_count), 0) FROM urls"
    ).fetchone()[0]
    conn.close()
    return {"total_urls": total_urls, "total_visits": total_visits}


@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    conn = get_db()
    conn.execute(
        "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?",
        (short_code,),
    )
    conn.commit()
    row = conn.execute(
        "SELECT original_url FROM urls WHERE short_code = ?", (short_code,)
    ).fetchone()
    conn.close()

    if not row:
        raise HTTPException(status_code=404, detail="Short URL not found")

    return RedirectResponse(url=row["original_url"], status_code=307)

What does each route do now?

  • POST /shorten inserts a new row into the database. If the random code collides with an existing one (IntegrityError), it generates a longer 8-character code and retries.
  • GET /api/urls queries the 20 most recent URLs, now including visit_count and created_at from the database.
  • GET /api/stats is brand new. It returns the total number of URLs created and the sum of all visits across every URL.
  • GET /{short_code} now increments the visit_count column before redirecting. This tracks how many times each short link is used.

Notice the comment about route ordering. /api/urls and /api/stats are declared before /{short_code} because FastAPI evaluates routes in order. If the catch-all came first, a request to /api/stats would be misinterpreted as a short code lookup.

  • Save main.py.
  • Start the server by running this command:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs in your browser. You should see the interactive docs with four endpoints listed: /shorten, /api/urls, /api/stats, and /{short_code}.

Seeing an import or syntax error?

  • Make sure you did not accidentally delete the # Pydantic models section or the generate_short_code function. Those sections should still be in your file between the database setup and the routes.
  • Check that from contextlib import asynccontextmanager is on its own line in the import block at the top of the file.
  • Confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.

Still stuck? Help me debug a FastAPI import or syntax error in my main.py after adding SQLite database functions.

Verify data persists across restarts

The whole point of adding a database is that your data survives a server restart. Time to prove it.

  • In the interactive docs at http://127.0.0.1:8000/docs, expand the POST /shorten section.
  • Click Try it out.
  • Replace the example body with:
{"url": "https://www.example.com/my-first-persistent-url"}
  • Click Execute. You should see a 200 response with your new short_code in the response body.
  • Create one or two more short URLs by changing the URL value and clicking Execute again.
  • Now expand the GET /api/stats section, click Try it out, then Execute. You should see a JSON response showing total_urls matching the number you created.
  • Go back to the terminal where the server is running.
  • Press Ctrl+C to stop the server.
  • Start it again by running:
fastapi dev main.py
  • Go back to http://127.0.0.1:8000/docs and test GET /api/stats again. Your total_urls count should be the same as before the restart.
  • Test GET /api/urls to confirm all your previously created URLs still appear in the list.

Your data survived the restart. This is the difference between in-memory storage and a real database.

Why does this matter for system design?

Stateless services (like your old dictionary-based API) lose everything on restart, crash, or scale-out. Stateful services backed by a database can recover from failures and serve consistent data to multiple processes.

This distinction is one of the first things interviewers probe in system design questions.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point. Compare yours against it:

import sqlite3
import string
import random
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel


# Database setup
def get_db():
    conn = sqlite3.connect("urls.db")
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE NOT NULL,
            original_url TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            visit_count INTEGER DEFAULT 0
        )
    """)
    conn.commit()
    conn.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    init_db()
    yield


app = FastAPI(title="URL Shortener", lifespan=lifespan)


# Pydantic models
class URLRequest(BaseModel):
    url: str


class URLResponse(BaseModel):
    short_code: str
    original_url: str
    short_url: str


# Helper function
def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


# Routes (specific paths BEFORE the catch-all path parameter)
@app.post("/shorten")
def shorten_url(request: URLRequest) -> URLResponse:
    short_code = generate_short_code()
    conn = get_db()
    try:
        conn.execute(
            "INSERT INTO urls (short_code, original_url) VALUES (?, ?)",
            (short_code, request.url),
        )
        conn.commit()
    except sqlite3.IntegrityError:
        short_code = generate_short_code(8)
        conn.execute(
            "INSERT INTO urls (short_code, original_url) VALUES (?, ?)",
            (short_code, request.url),
        )
        conn.commit()
    finally:
        conn.close()

    return URLResponse(
        short_code=short_code,
        original_url=request.url,
        short_url=f"http://localhost:8000/{short_code}",
    )


@app.get("/api/urls")
def list_urls():
    conn = get_db()
    rows = conn.execute(
        "SELECT short_code, original_url, visit_count, created_at FROM urls ORDER BY created_at DESC LIMIT 20"
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


@app.get("/api/stats")
def get_stats():
    conn = get_db()
    total_urls = conn.execute("SELECT COUNT(*) FROM urls").fetchone()[0]
    total_visits = conn.execute(
        "SELECT COALESCE(SUM(visit_count), 0) FROM urls"
    ).fetchone()[0]
    conn.close()
    return {"total_urls": total_urls, "total_visits": total_visits}


@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    conn = get_db()
    conn.execute(
        "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?",
        (short_code,),
    )
    conn.commit()
    row = conn.execute(
        "SELECT original_url FROM urls WHERE short_code = ?", (short_code,)
    ).fetchone()
    conn.close()

    if not row:
        raise HTTPException(status_code=404, detail="Short URL not found")

    return RedirectResponse(url=row["original_url"], status_code=307)

Your API now has real persistence. Next up, you will throw hundreds of simulated users at it and find the exact point where it breaks.

Load Test Until It Breaks

Your API now persists data to a database and survives restarts. But a single user clicking around in the docs UI does not tell you how many people can use it at the same time.

In this step, you will use Locust, a Python-based load testing tool, to simulate hundreds of users hitting your API simultaneously. You will watch real-time charts as your single-server system reaches its breaking point.

In this step, get ready to:
  • Write a Locust load test script that simulates realistic user traffic.
  • Run your API and the load test simultaneously.
  • Identify the saturation point where your API can no longer keep up.
Create the load test script

A load test simulates many users making requests to your API at the same time. Instead of testing one request at a time, you define user behavior in code and let Locust replay it with dozens or hundreds of concurrent users.

  • Create a new file called locustfile.py in your url-shortener folder (the same folder as main.py).
  • Add the imports, the user class, and the first task by pasting the following code:
from locust import HttpUser, task, between


class URLShortenerUser(HttpUser):
    wait_time = between(1, 3)

    @task(3)
    def create_short_url(self):
        self.client.post(
            "/shorten",
            json={"url": "https://example.com/page/" + str(hash(id(self)))},
        )

What does this code do?

You are defining a simulated user that Locust will clone many times over. Each clone behaves independently.

  • HttpUser gives each simulated user a built-in HTTP client for making requests to your server.
  • wait_time = between(1, 3) makes each user pause 1 to 3 seconds between actions, mimicking real human behavior.
  • @task(3) sets the task weight. A weight of 3 means this task is picked 3 times out of every 11 total picks (3 + 7 + 1 from the other tasks you will add next).
  • The create_short_url method POSTs to /shorten with a unique URL for each user instance.
  • Add the remaining two tasks below the create_short_url method, still inside the URLShortenerUser class:
    @task(7)
    def visit_short_url(self):
        response = self.client.post(
            "/shorten",
            json={"url": "https://example.com"},
            name="/shorten (for redirect)",
        )
        if response.status_code == 200:
            short_code = response.json()["short_code"]
            self.client.get(
                f"/{short_code}",
                name="/[short_code] (redirect)",
                allow_redirects=False,
            )

    @task(1)
    def get_stats(self):
        self.client.get("/api/stats")

What does this code do?

  • @task(7) on visit_short_url means 70% of requests will be redirects. This reflects real traffic patterns where most users click short links rather than create them.
  • The name parameter groups requests under one label in Locust's stats. Without it, every unique short code would appear as a separate row, making the stats unreadable.
  • allow_redirects=False measures only your API's response time, not the time to follow the redirect to the external URL.
  • @task(1) on get_stats adds occasional read-only database queries to the mix.
  • Save locustfile.py.
  • Confirm the file exists by running:
ls
  • You should see locustfile.py alongside main.py and urls.db in the output.

Don't see locustfile.py?

  • Make sure you created the file in the url-shortener folder, not inside a subfolder.
  • Check that the filename is exactly locustfile.py (all lowercase, no spaces). Locust looks for this name by default.

Help me find where my locustfile.py ended up.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

from locust import HttpUser, task, between


class URLShortenerUser(HttpUser):
    wait_time = between(1, 3)

    @task(3)
    def create_short_url(self):
        self.client.post(
            "/shorten",
            json={"url": "https://example.com/page/" + str(hash(id(self)))},
        )

    @task(7)
    def visit_short_url(self):
        response = self.client.post(
            "/shorten",
            json={"url": "https://example.com"},
            name="/shorten (for redirect)",
        )
        if response.status_code == 200:
            short_code = response.json()["short_code"]
            self.client.get(
                f"/{short_code}",
                name="/[short_code] (redirect)",
                allow_redirects=False,
            )

    @task(1)
    def get_stats(self):
        self.client.get("/api/stats")
Start the API server and run Locust

Load testing requires two processes running at the same time: your API server handling requests, and Locust generating the traffic. You will use two separate terminal windows for this.

  • Start your FastAPI server by running this command in your current terminal:
fastapi dev main.py
  • You should see output confirming the server is running at http://127.0.0.1:8000.
  • Open a second terminal window. Press Cmd+N (macOS) or Ctrl+Shift+N (Windows) in your terminal app to open a new window.

Why a second terminal?

The fastapi dev command occupies the first terminal. It continuously runs the server and prints request logs there. You need a separate terminal to launch Locust.

  • In the new terminal, navigate to your project folder and activate your virtual environment by running:
cd ~/Desktop/url-shortener
source venv/bin/activate
  • Start the Locust load tester by running:
locust
  • You should see output including a line mentioning the web interface starting at http://0.0.0.0:8089.
  • Open your browser and go to http://localhost:8089 to see the Locust web UI.

Locust not starting?

  • Make sure you activated the virtual environment in the second terminal. You should see (venv) in your prompt.
  • Confirm you are in the url-shortener directory. Locust looks for locustfile.py in the current directory.
  • If you see a port conflict, make sure no other process is using port 8089.

Help me get Locust running.

Ramp up users and find the breaking point

The Locust web UI lets you configure the test parameters and watch real-time performance charts. You will gradually increase the number of simulated users until your API cannot keep up.

  • In the Locust web UI at http://localhost:8089, fill in the following fields:
  • Set Number of users to 10.
  • Set Ramp Up (spawn rate) to 2.
  • Set Host to http://localhost:8000.
  • Click Start.

What do these fields mean?

  • Number of users is the peak number of concurrent simulated users. Each one runs the tasks you defined.
  • Ramp Up is how many new users Locust spawns per second until it reaches the target. A value of 2 means the load increases gradually.
  • Host is the base URL that all requests are sent to. This points at your locally running FastAPI server.
  • Click the Charts tab in the Locust web UI to see real-time graphs.
  • Watch the requests per second (RPS) and response time graphs for about 30 seconds. At 10 users, your API should handle the load comfortably.

Now you will increase the pressure. The goal is to find the exact point where your API starts struggling.

  • Click Edit in the top-right area of the Locust UI (next to the running user count).
  • Change Number of users to 50 and click Start.
  • Watch the Charts tab. If response times are still flat, increase to 100 users.
  • Keep increasing. Try 200 users. At some point, you will see response times spike sharply while RPS stops climbing.

What is the saturation point?

The saturation point is where your server is maxed out. You can identify it by two signals appearing together: response times start increasing rapidly, and requests per second stop growing even though Locust keeps adding users.

For a single-process FastAPI server with SQLite, this typically happens because SQLite uses file-level locking for writes. When many users try to write at the same time, they queue up waiting for the lock.

  • Once you have found the saturation point (response times spiking, RPS plateauing), note the approximate number of concurrent users where degradation started.
  • Click Stop in the Locust web UI to end the test.

Your numbers will vary

The exact breaking point depends on your computer's CPU, disk speed, and available memory. Somewhere between 20 and 100 users is typical for this setup. The important thing is that you can see the pattern: flat response times that suddenly spike.

Not seeing any degradation?

  • Make sure the Host field is set to http://localhost:8000 (not 8089, which is Locust itself).
  • Check the first terminal. If the FastAPI server crashed, restart it with fastapi dev main.py and re-run the test.
  • If your machine is very fast, try going to 300 or 500 users. Every system has a breaking point.

Help me interpret my Locust results.

Secret mission

Add an In-Memory Cache

Your load test revealed a saturation point. Every redirect request hits the database, even for URLs that were just looked up a moment ago. What if you could skip the database entirely for repeat visits? Add a cache layer and measure the real performance difference.

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:

  • A SQLite database file (urls.db) in your project directory.
  • Python virtual environment with installed packages (FastAPI, Locust).
  • The url-shortener project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with the next project in this series.

  • Your virtual environment, database, and project files stay exactly as they are.
  • You can restart the server anytime with fastapi dev main.py from inside the url-shortener folder.

Pause - I'll come back to this later

Shut down running processes and deactivate your virtual environment to free up memory. Your files stay intact for when you return.

  • Stop the FastAPI server if it is still running by pressing Ctrl+C in its terminal.
  • Stop Locust if it is still running by pressing Ctrl+C in its terminal.
  • Deactivate your virtual environment by running:
deactivate

When you come back, navigate to your url-shortener folder and reactivate the environment with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your code, database, and virtual environment permanently.

  • Stop any running servers (FastAPI or Locust) by pressing Ctrl+C in their terminals.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and remove the project folder.

On macOS/Linux:

cd ~/Desktop
rm -rf url-shortener

On Windows:

cd ~\Desktop
rmdir /s /q url-shortener

This removes everything: your Python files, the urls.db database, the virtual environment, and all installed packages.

That's a Wrap!

That's a Wrap!

Nice work! You just built, tested, and intentionally broke a real URL shortener API under load.

You now have firsthand experience with the performance concepts that system design interviews are built around.

You've learned how to:

  • Build a REST API from scratch with FastAPI, using HTTP methods, path parameters, request bodies, and automatic interactive documentation.
  • Add database persistence with SQLite, understanding why stateful systems behave differently than stateless ones.
  • Load test your own system with Locust, identifying the exact saturation point where your single server can no longer keep up under concurrent load.
  • Secret Mission: Implemented an in-memory cache layer and measured the real-world performance improvement, learning why caching is the first tool in every system designer's toolkit.

Ready to quiz yourself?