System Design ·

Designing a URL Shortener: From Interview Diagram to Production Reality

A production-focused system design walkthrough for building a URL shortener that stays fast, safe, and observable under real traffic, with practical TypeScript examples and clear tradeoffs.

Designing a URL Shortener: From Interview Diagram to Production Reality

URL shorteners are one of those system design problems that look clean in interviews and messy in production. On a whiteboard, you draw two boxes and feel done: one endpoint creates a short URL, another endpoint redirects. You mention a database, maybe a cache, and everyone nods.

The real version is different. A single viral link can create a hot key that shifts your latency profile in minutes. Analytics can quietly become your highest write volume and leak into the redirect path if you are not careful. Abuse shows up fast, usually before your dashboards are mature enough to help. The architecture itself is not hard, but the operational edge cases are where teams lose time.

This article walks from interview-level design to production-level design with one goal: keep the system boring under load. That means low-latency redirects, predictable write behavior, explicit tradeoffs, and operational guardrails that work when traffic is uneven and users are imperfect.

Start With the Real Shape of Traffic

Most shorteners are heavily read-dominant. Link creation is not the expensive path. Redirects are. A system that creates a few hundred thousand links per day can still serve tens of millions of redirects. That asymmetry should decide your priorities early.

If create traffic is 1x and redirect traffic is 100x, then every millisecond in redirect handling has more impact than almost any optimization in creation. The most common architectural mistake is giving equal design attention to both flows. They are not equal.

The redirect path should be short and deterministic: resolve code, validate state, return redirect, emit analytics asynchronously. Every extra branch adds tail latency and failure probability. The creation path can tolerate a little more work because it has lower QPS and is easier to retry.

Requirements That Change the Architecture

Before choosing storage or code generation, clarify a few requirements that materially affect design.

If links can expire, your hot path needs efficient expiration checks that do not require extra joins or expensive lookups. If custom aliases are allowed, collision handling and moderation become first-class concerns, not optional add-ons. If destination URLs can be updated after creation, caching strategy changes because stale redirects become a correctness issue, not just a performance issue. If analytics must be near real-time, your event pipeline and aggregate tables must be designed for low-latency reads.

These sound like product details, but each one changes core system behavior. You do not need perfect answers on day one, but you do need explicit assumptions. Hidden assumptions become production incidents.

A Data Model That Stays Practical

For most teams, a relational primary store is the right first choice for link mappings. You get strong constraints, clear indexing, and easier operational debugging when odd cases appear.

CREATE TABLE short_links (
  id BIGINT PRIMARY KEY,
  code VARCHAR(16) NOT NULL UNIQUE,
  long_url TEXT NOT NULL,
  user_id BIGINT NULL,
  created_at TIMESTAMP NOT NULL,
  expires_at TIMESTAMP NULL,
  is_active BOOLEAN NOT NULL DEFAULT true,
  updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_short_links_code ON short_links(code);
CREATE INDEX idx_short_links_user_created ON short_links(user_id, created_at DESC);

The redirect query should be a single indexed lookup by code. If you need tenant isolation, encode namespace in the key shape or include tenant in the index strategy, but keep the hot query simple. Analytics belongs elsewhere. Do not use the link mapping table for click-level write volume.

Code Generation: Simple Beats Clever

Teams usually debate two approaches: sequential IDs encoded in Base62, or random token generation.

Sequential plus Base62 is straightforward and collision-free by construction. Random tokens reduce predictability but require collision retries and occupancy-aware length decisions. In practice, sequential Base62 is easier to operate and reason about, especially at high write rates.

const BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

export function toBase62(n: bigint): string {
  if (n === 0n) return "0";

  let value = n;
  let out = "";
  while (value > 0n) {
    const rem = Number(value % 62n);
    out = BASE62[rem] + out;
    value = value / 62n;
  }
  return out;
}

The usual pushback is enumeration risk. That risk is real, but random tokens are not a complete defense. Abuse prevention, throttling, and anomaly detection are still required either way. It is often better to keep generation deterministic and handle abuse explicitly than to hide complexity in token logic.

Create Flow: Correctness Over Micro-Latency

The creation flow should enforce policy and integrity first. Latency matters, but consistency matters more here.

type CreateLinkInput = {
  longUrl: string;
  customAlias?: string;
  expiresAt?: string;
  userId?: string;
  idempotencyKey?: string;
};

type CreateLinkResult = {
  code: string;
  shortUrl: string;
  longUrl: string;
  expiresAt?: string;
};

export async function createLink(input: CreateLinkInput): Promise<CreateLinkResult> {
  const normalized = normalizeUrl(input.longUrl);
  if (!isAllowedUrl(normalized)) throw new Error("URL not allowed");

  const code = input.customAlias ?? toBase62(await nextSequenceId());

  await db.transaction(async (tx) => {
    if (input.idempotencyKey) {
      const prior = await tx.findCreateByIdempotencyKey(input.idempotencyKey);
      if (prior) return prior;
    }

    await tx.insertShortLink({
      code,
      longUrl: normalized,
      userId: input.userId ?? null,
      expiresAt: input.expiresAt ?? null,
      isActive: true,
    });

    if (input.idempotencyKey) {
      await tx.saveIdempotencyResult(input.idempotencyKey, code);
    }
  });

  return {
    code,
    shortUrl: `https://sho.rt/${code}`,
    longUrl: normalized,
    expiresAt: input.expiresAt,
  };
}

Unique constraints should be your final source of truth for alias collisions. Application checks are useful for better error messages, but database constraints are what protect correctness during races.

Redirect Flow: Keep It Thin

Redirect handling should do as little synchronous work as possible. The fast path is cache hit, lightweight validity check, redirect response. Analytics and enrichment should run out-of-band.

type CachedLink = {
  longUrl: string;
  isActive: boolean;
  expiresAt?: string;
};

export async function redirect(code: string): Promise<Response> {
  const cacheKey = `sl:${code}`;
  const cached = await cache.get<CachedLink>(cacheKey);

  if (cached) {
    if (!cached.isActive || isExpired(cached.expiresAt)) {
      return new Response("Not Found", { status: 404 });
    }

    enqueueClick({ code, ts: Date.now() });
    return Response.redirect(cached.longUrl, 302);
  }

  const row = await db.findByCode(code);
  if (!row || !row.isActive || isExpired(row.expiresAt ?? undefined)) {
    return new Response("Not Found", { status: 404 });
  }

  await cache.set(cacheKey, {
    longUrl: row.longUrl,
    isActive: row.isActive,
    expiresAt: row.expiresAt ?? undefined,
  }, ttlWithJitter(3600));

  enqueueClick({ code, ts: Date.now() });
  return Response.redirect(row.longUrl, 302);
}

You can use 301 for immutable links, but 302 is often safer by default because it preserves flexibility for destination updates and policy actions. Once permanent caching behavior spreads through clients and intermediaries, rollbacks are painful.

Core Tradeoffs

Production design is mostly disciplined compromise. The table below captures common decisions and their consequences.

DecisionOption AOption BWhat You GainWhat You PaySensible Default
Code generationSequential ID + Base62Random tokensDeterministic writes, no collisionsPredictable sequenceSequential + abuse controls
Redirect code302 temporary301 permanentFlexibility for updatesLess cache permanence302 unless immutable contract
Storage primaryRelational DBKV storeConstraints, rich queries, easier opsSlightly higher complexity than pure KVRelational + cache
Analytics writeInline counter updatesAsync event pipelineLower operational moving partsRedirect latency couplingAsync pipeline
Cache policyTTL onlyExplicit invalidation + TTLSimpler implementationStale data on updatesExplicit invalidation + TTL
Global scaleSingle-region originMulti-region write topologySimpler consistency modelHigher distant-region latencySingle write region + regional cache

A useful rule is to choose the option that makes failures easier to understand. Operability beats theoretical elegance.

Caching, Hot Keys, and Stampede Risk

Cache hit ratio is the multiplier behind both latency and cost. A healthy hit ratio keeps database load predictable even during bursts. A degraded hit ratio can turn a manageable traffic spike into database saturation.

Hot keys are unavoidable in this domain. One popular link can absorb a large share of total traffic. You should plan for this behavior before launch, not after the first campaign.

Use request coalescing where possible so simultaneous misses for the same key collapse into one backend fetch. Add TTL jitter to avoid synchronized expirations. Consider short stale serving windows when cache backfill is in progress. None of these tactics are exotic, but together they reduce p99 spikes significantly.

Cache invalidation also needs explicit handling. If a link is disabled or updated, stale entries should be purged immediately. TTL-only approaches are tempting but can create visible policy lag during abuse events.

Analytics Without Polluting Redirect Latency

Analytics feels simple until volume increases. The safe pattern is: emit event on redirect, buffer through queue or log, aggregate asynchronously, serve analytics from precomputed tables.

type ClickEvent = {
  code: string;
  timestamp: number;
  ipHash: string;
  country?: string;
  referrerDomain?: string;
  uaClass?: "desktop" | "mobile" | "bot" | "other";
};

export function buildClickEvent(req: Request, code: string): ClickEvent {
  return {
    code,
    timestamp: Date.now(),
    ipHash: sha256(getIp(req) ?? ""),
    country: req.headers.get("cf-ipcountry") ?? undefined,
    referrerDomain: normalizeReferrer(req.headers.get("referer")),
    uaClass: classifyUserAgent(req.headers.get("user-agent") ?? ""),
  };
}

Hashing source IP at ingestion gives you enough signal for approximate dedup and abuse monitoring while reducing privacy exposure. For most product needs, near-real-time aggregates are enough. Do not force exactly-once complexity unless you have a hard requirement for audit-grade counts.

Abuse Control Is Core Architecture

Public shorteners are abused quickly for phishing, malware, and cloaked redirects. Treat abuse prevention as a core feature of the platform, not an optional security layer.

At minimum, enforce creation rate limits per IP and per account, validate scheme and domain patterns, maintain deny lists, and provide a fast disable path that clears cache immediately. If disable actions take minutes to propagate, abuse windows remain large.

Moderation operations matter as much as detection quality. A decent classifier with fast review tooling outperforms an advanced classifier with slow action paths. Operational speed is part of system design.

Multi-Region Scaling: Delay Complexity Until Needed

Global traffic does not automatically require global write topology. Many teams can serve for a long time with centralized writes plus distributed read acceleration.

A practical maturity path starts with single-region writes and global edge caching. Then add regional caches and optional read replicas when latency data justifies it. Multi-primary writes and cross-region uniqueness protocols should arrive only when required by measured limits, because they significantly increase failure-mode complexity.

This staged model keeps your incident surface area small while the product is still changing. Premature global write design tends to solve future scale with present-day complexity.

Production Considerations

A URL shortener is production-ready when operational behavior is predictable, not when architecture diagrams look complete. The following areas usually separate robust systems from fragile ones.

First, reliability targets must be explicit. Define redirect availability and latency objectives, then instrument for those objectives directly. It is common to hit average latency targets while p99 quietly degrades due to hot-key misses or backend jitter.

Second, failure handling needs rehearsed runbooks. Cache outages, queue lag, replica delay, and domain abuse incidents should each have known mitigations and owners. If the first response to an incident is discovering who can disable links, your process is not ready.

Third, correctness controls must be enforced at storage boundaries. Unique constraints, transactional creation where needed, and idempotency for retried create requests prevent the most expensive class of logical bugs.

Fourth, observability should align with user impact. Track redirect success rates by status class, cache hit ratio by region, DB lookup latency on cache misses, analytics queue lag, and moderation turnaround time. These signals directly reflect system health.

Finally, load testing should model skewed traffic rather than uniform distributions. Uniform synthetic tests hide hot-key behavior and stampede dynamics, which are exactly what hurt this service in the real world.

Capacity Framing With Realistic Assumptions

Take a concrete profile: 35 million redirects per day, with a 10x burst factor, and 300 thousand new links per day. Average numbers look modest, but burst QPS during peak can exceed 4,000 redirects per second.

If cache hit ratio stays above 90 percent, the database load remains controlled. If hit ratio drops into the low 70s during burst traffic, miss volume can multiply backend pressure fast and create cascading latency. Capacity planning should include degraded-cache scenarios by default. Planning only for ideal cache behavior is not planning.

A useful practice is to run quarterly stress tests with one intentionally viral key and one high-churn key set. The first exposes hot-key saturation patterns. The second reveals eviction and refill behavior. Both are more realistic than evenly distributed random keys.

What “Good” Looks Like

A strong first-generation URL shortener is not defined by exotic technology choices. It is defined by discipline: deterministic code generation, strict storage constraints, cache-first redirects, asynchronous analytics, explicit abuse controls, and a measured scaling path.

Interview answers test whether you know the components. Production systems test whether you can choose where complexity belongs. In this problem, complexity belongs away from the redirect path and inside controlled asynchronous boundaries.

If you keep that boundary clean, most scaling challenges stay manageable. If you blur it, every traffic spike becomes a systems problem and an operations problem at the same time.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.