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
- Design create and redirect paths that fit on one server.
- Choose a short-code strategy and handle collisions safely under concurrent requests.
- Implement bounded caching without making the cache the permanent record.
- Estimate which resources run out as traffic grows.
Builds on 5 lessons
Start with lesson 515 minVideo 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.
Two sellers receive the same randomly generated code. Must one shopper eventually reach the wrong destination?Show answer
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
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
- The seller sends a destination to the application.
- The application authenticates the seller and validates the destination.
- It generates a candidate code.
- PostgreSQL attempts to insert the mapping.
- 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
- A browser requests
/aB7k2Q9x. - The application checks the code’s format.
- PostgreSQL looks up that exact code.
- The application returns a redirect with the saved destination.
- 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.
Generate random codes and let the database reject collisions.
Generate eight random characters and attempt an insert. A conflicting insert changes nothing; the application generates another candidate. Each creation gets a separate campaign address without maintaining a counter.
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 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:
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:
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:
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:
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.
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/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/1.1 302 Found
Location: https://kartly.example/products/381?campaign=festival
Cache-Control: no-store
Content-Length: 0We 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.
Add a bounded cache inside each application worker.
Each application worker remembers popular mappings. Hits avoid a database request. Memory usage and duplicate copies must be bounded, and restarting a worker empties its cache.
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.
| 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:
- Validate the code format.
- Check the worker-local cache.
- On a usable hit, return the saved destination.
- On a miss, query PostgreSQL by primary key.
- Cache a successful lookup, then return the redirect.
- 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:
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 targettype 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 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
404on 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
Two create requests generate the same code, and both reach PostgreSQL together. What prevents the second request from replacing the first destination?Show answer
A worker returns a redirect for a code, then restarts. Must the link stop working?Show answer
The database times out while looking up a code. Why is returning 404 incorrect?Show answer
A cache hits on 95% of 100,000 redirects a second. Does that prove one server is enough?Show answer
Summary
- One server can create unique links and redirect quickly by keeping permanent mappings in PostgreSQL and repeated reads in a bounded local cache.
- Generate a candidate, then insert atomically. Randomness reduces collisions; the unique constraint preserves correctness.
- Keep creation and navigation separate. Return success after commit, and never confuse a timeout with a missing link.
- Estimate writes, storage, cache misses and CPU independently. A high hit rate does not remove every capacity limit.
- Design for empty caches and bounded waiting. One host still means one shared outage boundary.