lumo

Module 01 · Lesson 13 of 14Free

Design: a URL shortener on a single server

Level
Intermediate
Topic
System design fundamentals
Reading time
30 min

When you finish this page you can

  1. Design create and redirect paths that fit on one server.
  2. Choose a short-code strategy and handle collisions safely under concurrent requests.
  3. Implement bounded caching without making the cache the permanent record.
  4. Estimate which resources run out as traffic grows.

Builds on 5 lessons

Start with lesson 515 min
  1. Functional and non-functional requirements15 min
  2. Capacity estimation: users, storage and traffic14 min
  3. Vertical and horizontal scaling: when to use each14 min
  4. Stateless and stateful services: impact on scaling18 min
  5. Cost-aware design: avoiding unnecessary infrastructure18 min

Video lesson · coming soon

A seller shares a festival offer through Links. In the first minute, 1,000 shoppers a second open its short address. The team must serve them from the existing eight-core server, alongside the store.

How can we create unique short links and redirect shoppers quickly without adding another server?

All link-service traffic, resource budgets and benchmark results below are assumptions for this lesson. They are not measured Kartly statistics. Kartly remains at its one-server stage, with one PostgreSQL database.

A is a small identifier such as aB7k2Q9x. The database maps it to a longer destination address. Opening the short address returns a redirect: an response telling the browser where to go next.

Check yourselfAnswer in your head first, then reveal
Two sellers receive the same randomly generated code. Must one shopper eventually reach the wrong destination?Show answerHide answer
No. Random generation proposes a code; the database decides whether it is available. A unique constraint rejects the second insert, and that request tries another code.

The design needs two paths. Sellers create mappings. Shoppers read them. The second path will dominate traffic, but the first determines whether every redirect stays correct.

Requirements set the server’s boundaries

Start with product questions, using the distinction from Functional and non-functional requirements.

Product question Answer for this version Design consequence
Who creates links? Signed-in Kartly sellers Authenticate creation; record the seller
Who opens them? Anyone with the address Redirects need no login
Which destinations are allowed? product pages on kartly.example Reject other hosts and schemes
Can destinations change? No; create another link instead Existing mappings remain immutable
Do links expire? Not in this version Preserve mappings; never reuse codes
Are duplicate destinations allowed? Yes Two campaigns can have separate links
Do sellers need click reports? Not yet No database write on each redirect

For this scenario, the team proposes a server-side redirect target of p95 below 20 ms. That excludes the shopper’s network and the destination page load. A fast redirect cannot make a slow product page fast.

An acknowledged creation must survive an application restart. The database owns permanent mappings; application memory cannot be their only home.

One server is a hard constraint, not an availability guarantee. A host failure interrupts the store and its short links together. The team accepts that limitation for this version.

We also exclude custom aliases, previews and destination editing. Supporting arbitrary external destinations would require a separate abuse policy. Restricting destinations keeps a seller’s link from becoming a route to an unrelated phishing page.

The boundary is now clear. Next, we need to know whether the workload fits inside it.

Small writes justify a simple start

Assume sellers create 2,000 links each day. Redirect traffic normally reaches about 50 requests a second, then peaks at 1,000.

These are link-service estimates. They do not replace the store’s existing workload.

Following Capacity estimation, separate storage, writes and peak reads.

Average creation rate

2,000 links/day ÷ 86,400 seconds/day ≈ 0.02 creates/second

Even a scenario burst of 10 creates a second is small. Creation does not justify a separate write service.

One year of mappings

2,000 links/day × 365 days = 730,000 links

Assume an average stored mapping occupies 1 KB before indexes and operational space.

Storage allowance

730,000 × 1 KB ≈ 0.73 GB; allow twice that ≈ 1.5 GB

This rough allowance covers the table and indexes, not backups or every possible database overhead. Reserve 20 GB for the feature and measure actual growth.

Peak application CPU

1,000 redirects/second × 0.5 ms CPU/redirect = 0.5 CPU-seconds/second

Assume profiling gives this cost for the uncached path. It fits a proposed two-core CPU budget for Links.

Assume a mixed-workload test, including the store, leaves capacity for about 1,500 additional mapping reads a second within the latency target. Our initial peak fits that assumed result.

These estimates justify starting with direct database lookups. They do not prove capacity. Long URLs, connection waits and shared disk activity could invalidate the assumptions.

The first implementation should expose those costs rather than hide them behind extra infrastructure.

Version 1 stores every mapping

Version 1: creation writes one mapping; each redirect reads it. The browser, not the shortener, fetches the destination.

The Links application runs as a small service on the existing host. PostgreSQL also lives there. We can run several application worker processes without adding physical servers; Server concurrency explains that distinction.

Component Responsibility Talks to State it keeps Failure behavior
Links application Validate, allocate codes, return redirects Clients, PostgreSQL Connections and requests in progress Requests fail during restart
PostgreSQL Preserve mappings and enforce uniqueness Links application Rows and indexes on disk Creation and uncached redirects stop
Browser Follow the returned destination Links application, Kartly Current navigation Shopper sees a network or navigation error

The create path

  1. The seller sends a destination to the application.
  2. The application authenticates the seller and validates the destination.
  3. It generates a candidate code.
  4. PostgreSQL attempts to insert the mapping.
  5. After the insert commits, the application returns the short address.

The response must come after commit. Otherwise, a seller could share an address that never became permanent.

The redirect path

  1. A browser requests /aB7k2Q9x.
  2. The application checks the code’s format.
  3. PostgreSQL looks up that exact code.
  4. The application returns a redirect with the saved destination.
  5. The browser makes a separate request to Kartly.

The shortener does not download or proxy the product page. It only returns an address. This keeps images and page rendering outside its resource budget.

An unknown code produces 404. A database timeout produces 503, not 404: unavailable storage does not mean the link is absent. Those meanings follow HTTP’s distinction between a missing resource and temporary service unavailability.1

The read path is short. The harder question is how creation assigns a code without ever overwriting someone else’s mapping.

Codes need allocation, not just randomness

A code can use digits, lowercase letters and uppercase letters. That gives an alphabet of 62 characters.

Encoding a number with that alphabet is called . With the alphabet 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ, the number 61 becomes Z, and 62 becomes 10.

There are three useful ways to allocate codes.

How should Kartly Links assign short codes?

Derive the code from a destination hash.

Hash the destination, then keep enough output to make a short code. The same destination naturally produces the same candidate. Different destinations can still collide after truncation, so storage must detect conflicts and the allocator needs a fallback.

Encode a database-allocated counter in base62.

Allocate an increasing integer and encode it in base62. Values are compact and unique if allocation is atomic. Nearby values are easy to enumerate, and the mapping depends on preserving the counter’s state.

Why this choice

Our product allows separate links to the same destination. A destination hash therefore gives us less useful behavior than it first appears.

A database counter would work well on this single host. We choose random codes because adjacent campaign addresses should not reveal an obvious sequence. This is convenience, not access control: anyone holding a link may open it.

The choice changes if shortest possible codes matter more than enumeration. Then a database-allocated counter is attractive. Hash-derived codes become attractive when identical destinations should share one identity, but truncated hashes still need conflict handling.

The simplest random design checks whether a candidate exists, then inserts it. That separate check is unsafe under : another request can insert the candidate between those operations.

Instead, attempt the insert directly. PostgreSQL’s ON CONFLICT DO NOTHING skips a conflicting insert. RETURNING reports only successfully inserted rows.3

A collision costs another attempt. It must never replace an existing mapping.

A large space still has collisions

Available eight-character codes

62⁸ = 218,340,105,584,896 possibilities

This is about 218 trillion possible codes.

Chance of a collision on the next attempt

730,000 occupied codes ÷ 218 trillion possibilities ≈ 1 in 300 million

This assumes independent, uniformly distributed choices and a database containing one year of links.

That is the chance for the next attempt, not the chance of any collision across the whole year. We still require the unique constraint.

A collision is ordinary control flow. Repeated collisions are an operational signal: perhaps the generator is broken. Bound the attempts and return an error rather than looping forever.

The next step is to make the database contract precise enough for the allocator to use safely.

The database owns the invariant

PostgreSQL owns this schema:

sql
CREATE TABLE short_links (
    code        VARCHAR(8) COLLATE "C" PRIMARY KEY,
    seller_id   BIGINT NOT NULL,
    target_url  TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (code ~ '^[0-9A-Za-z]{8}$'),
    CHECK (octet_length(target_url) BETWEEN 1 AND 2048)
);

The invariant is simple: one code identifies at most one destination.

The serves both the exact-code lookup and uniqueness enforcement. We need no destination index because this version never searches by destination. Indexes develops that mechanism further.

Codes are case-sensitive. The application and database must treat aB7k2Q9x differently from Ab7k2Q9x. The explicit collation makes the intended comparison rule visible.

seller_id comes from authenticated identity, not the request body. It records ownership without adding a seller-list endpoint. If that endpoint becomes necessary, add the index for its actual query.

The application validates a destination before this insert:

sql
INSERT INTO short_links (code, seller_id, target_url)
VALUES ($1, $2, $3)
ON CONFLICT (code) DO NOTHING
RETURNING code, target_url;

One returned row means allocation succeeded. No returned row means the candidate collided. A timeout is neither outcome; treat it as an uncertain result, not permission to claim success.

The insert adapter must finish its transaction before returning success. Concurrent inserts for the same code may wait for each other before the conflict is resolved. PostgreSQL documents that unique-index conflicts can block concurrent inserts.3

The allocator has one job

These examples belong to the Links application. The supplied insert callback executes the statement above and returns only after commit. It returns false only for a code conflict; database failures raise an error.

Python:

python
import secrets
from collections.abc import Awaitable, Callable

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
Insert = Callable[[str, int, str], Awaitable[bool]]

async def create_link(
    seller_id: int,
    target_url: str,
    insert: Insert,
) -> str:
    for _ in range(5):
        code = "".join(secrets.choice(ALPHABET) for _ in range(8))
        if await insert(code, seller_id, target_url):
            return code
    raise RuntimeError("Code allocation failed")

TypeScript, using Node.js’s built-in crypto module:

ts
import { randomInt } from "node:crypto";

const ALPHABET =
  "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

type Insert = (
  code: string,
  sellerId: number,
  targetUrl: string
) => Promise<boolean>;

export async function createLink(
  sellerId: number,
  targetUrl: string,
  insert: Insert
): Promise<string> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const code = Array.from(
      { length: 8 },
      () => ALPHABET[randomInt(ALPHABET.length)]
    ).join("");
    if (await insert(code, sellerId, targetUrl)) return code;
  }
  throw new Error("Code allocation failed");
}

There is no “is this code free?” request. The insert is the decision point.

A database error must not become false in the adapter. Otherwise, an unavailable database looks like five collisions and hides the real failure.

Allocation is now race-safe. We still need a clear contract for callers, especially callers that retry.

The API separates creation from navigation

The Links application owns both endpoints:

Endpoint Input Successful result Retry behavior
POST /api/links Authenticated seller and destination 201 with code and short address May create another alias
GET /:code Case-sensitive code 302 with destination No mapping changes
HEAD /:code Same code Same redirect headers, no body No mapping changes

Creation is deliberately not idempotent in this version. Two successful requests may create two links to the same destination. That matches our product requirement, but a client retry after a timeout can leave an unused alias.

If the product later promises one campaign record per creation attempt, add an : a caller-provided identifier reused across retries. That is a separate requirement from code uniqueness.

http
POST /api/links HTTP/1.1
Host: ktly.example
Authorization: Bearer seller-session
Content-Type: application/json

{
  "target_url": "https://kartly.example/products/381?campaign=festival"
}
http
HTTP/1.1 201 Created
Content-Type: application/json
Cache-Control: no-store

{
  "code": "aB7k2Q9x",
  "short_url": "https://ktly.example/aB7k2Q9x"
}

Validation parses an absolute URL, then checks the scheme and hostname. It requires HTTPS and exactly kartly.example, rejects credentials and control characters, and limits the stored URL to 2,048 UTF-8 bytes.

A string-prefix test is not enough: kartly.example.attacker.example is a different hostname. The application also checks that the path matches a supported product route. It does not fetch the destination during creation.

Failure Response
Invalid destination or oversized URL 400
Missing seller identity 401
Seller exceeds the creation allowance 429
Code absent or malformed 404
Database unavailable or allocation cannot finish 503

These are our endpoint choices, not additional guarantees from the status codes.

Redirect headers are a product decision

http
HTTP/1.1 302 Found
Location: https://kartly.example/products/381?campaign=festival
Cache-Control: no-store
Content-Length: 0

We choose a temporary redirect rather than advertising a permanent move. HTTP defines 302 as a temporary location supplied through Location.1

We also explicitly disable HTTP response storage. no-store instructs compliant HTTP caches not to store the response. This does not forbid our application from caching database mappings internally.2

That choice keeps navigation reaching our service. If reducing origin traffic later matters more, we could permit short-lived browser caching. We would then lose some visibility into visits and some control over already-cached responses.

For now, every navigation reaches Links. That makes repeated lookups our next cost to address.

Repeated reads justify a local cache

One campaign overwhelms the read budget

A seller’s campaign becomes popular. Assume Links now receives 10,000 redirects a second.

Every redirect still asks PostgreSQL for a mapping. That exceeds our assumed spare read capacity, and requests wait for connections. Shoppers see a pause before the product page even starts loading.

Adding application workers would send more requests toward the same busy database. We need to remove repeated work.

A cache stores recently used mappings in application memory. A hit finds the code there. A miss loads it from PostgreSQL.

Where should repeated redirect lookups be served?

Continue querying PostgreSQL for every redirect.

The simplest version stays unchanged. It wins when the database meets the target with spare capacity, including its own memory caching. It does not address our assumed application-to-database read bottleneck.

Run a process on the same server.

A separate cache process on the same host can share entries across workers. It adds a protocol, memory allocation and another process to supervise. The machine remains the common failure boundary.

Why this choice

Choose worker-local memory first. Our mappings are immutable, so workers can safely remember the same mapping independently.

A shared process becomes useful if duplicate entries waste substantial memory or workers churn frequently. At this scale, avoiding another dependency is worth some duplication.

We would also reconsider browser caching if the product no longer needed each visit to reach Links. That reduces requests to the application, not just database queries.

Version 2 adds only worker-local caches. PostgreSQL still owns every permanent mapping.
Component Responsibility Talks to State it keeps Failure behavior
Links application Same API and validation Clients, , PostgreSQL Requests in progress Restart loses local caches
Worker-local cache Avoid repeated mapping reads Its application worker Bounded code-to-destination entries Misses fall back to PostgreSQL
PostgreSQL Same permanent record Application workers Mappings and indexes Hits work temporarily; misses and creates fail

The create path is unchanged. Cache population is optional after commit; correctness never depends on it.

The updated read path is:

  1. Validate the code format.
  2. Check the worker-local cache.
  3. On a usable hit, return the saved destination.
  4. On a miss, query PostgreSQL by primary key.
  5. Cache a successful lookup, then return the redirect.
  6. Do not cache database errors as missing links.

Set an entry’s lifetime to one minute in this scenario. That lifetime is its , or . It bounds residence time; it is not needed to refresh immutable destinations.

A simple policy evicts the entry unused for the longest time when capacity is full. This favors recently popular campaigns without requiring a prediction of tomorrow’s traffic.

Keep the cache disposable and bounded

These examples implement the lookup mechanism inside one event-loop worker. load reads PostgreSQL asynchronously and returns null only when the code is absent.

Python:

python
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from time import monotonic

Loader = Callable[[str], Awaitable[str | None]]

class LinkCache:
    def __init__(self, capacity: int = 10_000) -> None:
        if capacity < 1:
            raise ValueError("capacity must be positive")
        self.capacity = capacity
        self.entries: OrderedDict[str, tuple[str, float]] = OrderedDict()

    async def resolve(self, code: str, load: Loader) -> str | None:
        item = self.entries.pop(code, None)
        if item is not None and item[1] > monotonic():
            self.entries[code] = item
            return item[0]

        target = await load(code)
        if target is not None:
            self.entries.pop(code, None)
            self.entries[code] = (target, monotonic() + 60)
            while len(self.entries) > self.capacity:
                self.entries.popitem(last=False)
        return target
ts
type Loader = (code: string) => Promise<string | null>;
type Entry = { target: string; expires: number };

export class LinkCache {
  private readonly entries = new Map<string, Entry>();

  constructor(private readonly capacity: number = 10_000) {
    if (!Number.isInteger(capacity) || capacity < 1) {
      throw new Error("capacity must be a positive integer");
    }
  }

  async resolve(code: string, load: Loader): Promise<string | null> {
    const item = this.entries.get(code);
    this.entries.delete(code);
    if (item !== undefined && item.expires > performance.now()) {
      this.entries.set(code, item);
      return item.target;
    }

    const target = await load(code);
    if (target !== null) {
      this.entries.delete(code);
      this.entries.set(code, {
        target,
        expires: performance.now() + 60_000
      });
      while (this.entries.size > this.capacity) {
        const oldest = this.entries.keys().next();
        if (oldest.done) break;
        this.entries.delete(oldest.value);
      }
    }
    return target;
  }
}

These functions are not complete HTTP handlers. The surrounding application handles validation, deadlines and response headers.

Concurrent misses may perform duplicate reads. They cannot change the saved destination. A per-code shared in-progress lookup can reduce that duplication later.

For threaded use, cache mutations need synchronization. These examples assume mutations run on one event-loop thread.

Cache placement later compares more locations. Here, the immediate question is whether this small addition removes enough work.

Growth changes the limiting resource

Assume the on 95% of redirects. That is a traffic assumption, not a property of having a cache.

Also assume a hit costs 0.1 ms of application CPU. A miss still costs 0.5 ms.

Database reads at 10× peak traffic

10,000 redirects/second × 5% misses = 500 reads/second

This fits the assumed spare database capacity. Creation remains a small workload.

Average application CPU cost

95% × 0.1 ms + 5% × 0.5 ms = 0.12 ms/redirect

The estimate includes hits and misses, but excludes the store’s CPU.

Application demand at 10×

10,000 redirects/second × 0.12 ms = 1.2 CPU-seconds/second

This fits the proposed two-core Links budget. Verify it alongside the store before accepting the traffic target.

Demand at 100×

100,000 × 5% = 5,000 database reads/second; 100,000 × 0.12 ms = 12 CPU-seconds/second

Both assumed limits fail. Application CPU alone exceeds the entire eight-core host.

For a scenario with four workers, each holding 10,000 entries, assume an average cached entry costs 2 KB. That gives an estimated 80 MB across workers. Runtime overhead varies, so reserve more and measure memory rather than treating an entry count as a byte limit.

Traffic condition Likely first limit under our assumptions Response within one server
Initial peak, uncached No estimated limit reached Keep the simple design; measure
10× peak, uncached Database read budget Add the local cache
10× peak, 95% hits Fits estimated budgets Check cold starts and traffic distribution
100× peak, 95% hits CPU and database reads Reduce work, constrain traffic or change the product target

A popular link helps caching. Uniform requests across millions of codes may not. If the hit rate falls to zero, the cached design approaches the original database workload.

At 100×, a larger allowed host, cheaper request handling or browser caching might help. None is an automatic answer. If measurements still exceed one machine’s limits, we must reject excess traffic or revisit the hard constraint.

The later URL-shortener design addresses a different deployment boundary. We do not introduce that architecture here.

A design that fits steady traffic still needs a plan for the moment its cache disappears.

Restarting exposes the uncached system

When workers restart, their caches are empty. Previously cheap hits become database reads.

Cap concurrent database operations across all Links workers. For this scenario, start with a combined limit of 20, then tune it against the store’s workload. A request also needs a deadline; a small connection allowance without deadlines can still become permanently occupied.

When the allowance is exhausted, permit only a short, bounded . Beyond that, return 503. Some shoppers see a retryable error, but the service avoids consuming unbounded memory and blocking the store.

Failure What the user sees Detection Recovery
Worker restarts Some interrupted requests Process exits and error rate Supervisor restarts it; cache fills again
PostgreSQL unavailable Creates and misses return 503 Database timeouts and Restore database; successful hits work only until expiry
Cache entries churn Slower redirects Falling hit rate, rising database reads Check traffic mix and memory budget
Host stops All short links unavailable External availability probe Repair or restart host; restore storage if necessary
Disk approaches full Creation failures may begin Free-space and write-error alerts Free safe operational space or expand allowed storage

Do not fill the cache before committing a new mapping. A crash between those operations could expose a link that existed only in memory.

Keep recoverable backups and verify restoration separately from restart behavior. With one serving host, backups help recovery; they do not provide uninterrupted service.

We now have a fast normal path, an explicit overload boundary and an honest host-failure limitation.

Your turn

Traffic stays at 10,000 redirects a second, but the cache hit rate falls from 95% to 50%. The spare database budget remains 1,500 reads a second. Predict the result and choose your first response without adding a server.

Show the model answerHide the model answer

The application now sends 5,000 mapping reads a second. That exceeds the assumed budget.

First protect the database with the existing concurrency cap, deadlines and bounded queue. Inspect which codes miss before changing memory limits.

A larger cache helps only if useful entries were being evicted. It will not help much when most requests ask for codes used once. Browser caching could reduce repeat navigations, but only if the product accepts that policy.

Do not start by adding application workers. They do not increase the shared database budget.

Common mistakes

  • Checking before inserting. Another creator can win between those operations. Let the unique constraint decide.
  • Overwriting on conflict. A seller’s published address must never start pointing to someone else’s product.
  • Writing a click counter on every redirect. That adds a write workload the requirements did not request.
  • Returning 404 on database errors. It tells shoppers a valid campaign does not exist.
  • Growing the cache without a limit. Cache memory competes with the application, PostgreSQL and the store.

Each mistake moves a cost or failure into a less visible place. Keep the permanent mapping and the request’s resource budget explicit.

Going deeper

When the simplified policies need more work

. Many simultaneous misses for one code can share one in-progress database lookup per worker. Remove that shared entry after success or failure. Otherwise, a failed operation can leave future callers waiting forever.

Emergency blocking. Immutable destinations simplify correctness, but a product may still need to disable a harmful link. That requires a stored disabled flag and a policy for clearing cached entries. Our current cache can retain a mapping until its TTL expires.

Memory accounting. The sample cache bounds entry count, not total bytes. Variable-length destinations and runtime overhead require a byte budget or conservative entry limits.

Creation retries. Code uniqueness protects mappings, not campaign identity. If an unused duplicate alias becomes unacceptable, store a seller-scoped idempotency key atomically with creation.

The next load-testing lab asks whether these assumed limits match a server’s measured behavior.

Check your understanding

Check yourselfAnswer in your head first, then reveal
Two create requests generate the same code, and both reach PostgreSQL together. What prevents the second request from replacing the first destination?Show answerHide answer
The primary key allows only one mapping for that code, and ON CONFLICT DO NOTHING never overwrites it. The request that inserts no row generates another candidate.
Check yourselfAnswer in your head first, then reveal
A worker returns a redirect for a code, then restarts. Must the link stop working?Show answerHide answer
No. The mapping is committed in PostgreSQL, so the restarted worker can load it again. Only its disposable cached copy disappeared.
Check yourselfAnswer in your head first, then reveal
The database times out while looking up a code. Why is returning 404 incorrect?Show answerHide answer
The application does not know whether the mapping exists. Return 503 for the temporary dependency failure rather than claiming the resource is absent.
Check yourselfAnswer in your head first, then reveal
A cache hits on 95% of 100,000 redirects a second. Does that prove one server is enough?Show answerHide answer
No. There are still 5,000 database reads a second, and every hit consumes application CPU. Both exceed the lesson’s assumed budgets.

Summary

  1. One server can create unique links and redirect quickly by keeping permanent mappings in PostgreSQL and repeated reads in a bounded local cache.
  2. Generate a candidate, then insert atomically. Randomness reduces collisions; the unique constraint preserves correctness.
  3. Keep creation and navigation separate. Return success after commit, and never confuse a timeout with a missing link.
  4. Estimate writes, storage, cache misses and CPU independently. A high hit rate does not remove every capacity limit.
  5. Design for empty caches and bounded waiting. One host still means one shared outage boundary.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.