A URL shortener looks trivial — hash a string, store a mapping, redirect — which is exactly why it survives as a system design staple: push past the toy version and every hard distributed-systems problem shows up in miniature. Interviewers use it to see whether a senior or architect candidate can turn "map a short code to a long URL" into a real conversation about ID generation without a single point of contention, a data store that answers billions of point lookups cheaply, a cache that survives a hot-key spike, and the genuinely consequential choice between a 301 and a 302 redirect. It rewards candidates who size the problem before drawing boxes, and it exposes candidates who jump straight to "just hash it" without reasoning about collisions, skew and abuse. This page works through the design the way a real architect-level loop runs: requirements and estimates first, then the deep dives an interviewer keeps pulling on until something breaks.

Q1 How would you spend the first five minutes of this interview — what requirements and scale estimates do you establish before designing anything?#

Short answer: Nail down the functional scope (custom aliases, expiration, per-user link management, analytics), the non-functional priorities (this system is read-heavy and latency-sensitive on the redirect path, and it can tolerate a little staleness on writes), and then convert an assumed traffic figure into concrete numbers for QPS, storage growth and cache size before touching a whiteboard box.

Start with functional requirements: does the system need custom aliases, link expiration, click analytics, and user accounts that own and manage their links, or is it purely anonymous shorten-and-redirect? Each answer changes the data model. Then move to non-functional requirements, and say the read:write ratio out loud — a typical shortener sees on the order of 100:1 to 1,000:1 reads to writes, because one shortened link gets clicked many times. That ratio is the single most important number in the whole design: it tells you to optimize the redirect path (GET /{code}) ruthlessly and to accept more latency and complexity on link creation. From there, do the back-of-envelope math with a stated assumption, for example 500 million new links per month and a 500:1 read ratio: that's roughly 190 writes/second average and around 95,000 reads/second average, with peak traffic several times higher. At 500 million links a year and a compact record (short code, target URL, metadata) of a few hundred bytes, storage grows by tens of gigabytes a year — trivial for capacity planning, which tells you storage cost isn't the constraint; read throughput and redirect latency are.

What interviewers look for: whether you derive the read-heavy assumption yourself and let it drive every later decision (caching, redirect type, storage choice), rather than treating requirements gathering as a box-ticking exercise before the "real" design starts.

Common mistakes: skipping straight to schema design without stating a read:write ratio, or spending ten minutes precision-estimating storage when the real bottleneck at this scale is always read throughput and cache hit rate.

Q2 Design the core API surface. What endpoints, request and response shapes, and status codes would you expose?#

Short answer: Two endpoints carry almost all the traffic — POST /api/urls to create a short link and GET /{code} to resolve and redirect — plus a management endpoint to fetch or deactivate a link by code; the creation endpoint needs idempotency and validation, and the redirect endpoint needs to be the fastest, simplest code path in the service.

The creation endpoint accepts the target URL, an optional custom alias, and an optional expiration, validates the URL (scheme, length, not pointing back at your own domain to avoid redirect loops), and returns 201 Created with the new short URL, or 409 Conflict if a requested custom alias is taken. The redirect endpoint takes the code from the route, resolves it — cache first, store on miss — and issues an HTTP redirect, or 404 Not Found if the code doesn't exist or has expired. Keep this handler minimal: no business logic beyond the lookup, because every millisecond here is multiplied by your highest-traffic path.

C#
var app = WebApplication.Create(args);

app.MapPost("/api/urls", async (CreateUrlRequest request, IUrlShortenerService service,
    CancellationToken cancellationToken) =>
{
    var result = await service.CreateAsync(request.TargetUrl, request.CustomAlias,
        request.ExpiresAt, cancellationToken);
    return result.IsSuccess
        ? Results.Created($"/{result.Code}", result)
        : Results.Conflict(result.Error);
});

app.MapGet("/{code}", async (string code, IUrlShortenerService service,
    CancellationToken cancellationToken) =>
{
    var target = await service.ResolveAsync(code, cancellationToken);
    return target is not null ? Results.Redirect(target, permanent: false) : Results.NotFound();
});

app.Run();

record CreateUrlRequest(string TargetUrl, string? CustomAlias, DateTimeOffset? ExpiresAt);

What interviewers look for: that the redirect handler stays deliberately thin, and that you called out validation concerns (open redirects, self-referential loops) unprompted rather than only after being asked about security.

Follow-up questions:

  • How would you version this API as the request and response shapes evolve?
  • Would you expose a bulk-creation endpoint, and how would that change the write path?

Q3 Walk through three approaches to generating short codes and their trade-offs: an incrementing counter, random generation with collision checks, and Snowflake-style IDs.#

Short answer: A base62-encoded counter is simple and collision-free but centralizes writes through one sequence and leaks your total link count; random generation avoids central coordination but needs a collision check on every write; a Snowflake-style ID embeds a timestamp and a worker ID so any node can mint unique, roughly time-ordered IDs with no coordination at all — which is the approach that scales best.

Base62 uses the 62-character alphabet 0-9A-Za-z, so a 7-character code addresses 62^7, or about 3.5 trillion values — comfortably more than any realistic link volume. A counter-based scheme takes the next value from an auto-incrementing sequence and encodes it: trivially unique and dense, but every writer contends on the same sequence, and a curious user can decode a code back into an approximate creation order, which is an information leak most products don't want. Pure random generation (draw 6 to 8 random base62 characters) avoids the shared sequence, but the birthday paradox means collisions become non-trivial as the keyspace fills, so every write needs an existence check — and under contention, a check-then-insert race needs a unique index and a retry loop rather than a naive check-then-write. A Snowflake-style 64-bit ID composes a millisecond timestamp, a worker/shard identifier and a per-millisecond sequence number into one value: every node generates unique IDs independently, the IDs sort roughly by creation time (which keeps database index pages and cache locality favorable), and there's no shared counter to contend on. In .NET 9 and later, Guid.CreateVersion7() gives you an equivalent property out of the box — a time-ordered UUID (RFC 9562 version 7) — without hand-rolling the bit layout, though you'd still base62-encode or truncate it to get a short, URL-friendly code.

C#
private const string Base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

private static string ToBase62(long value)
{
    if (value == 0) return Base62Alphabet[0].ToString();

    Span<char> buffer = stackalloc char[11];
    var position = buffer.Length;
    while (value > 0)
    {
        buffer[--position] = Base62Alphabet[(int)(value % 62)];
        value /= 62;
    }

    return new string(buffer[position..]);
}

What interviewers look for: a clear-eyed trade-off table in your head — coordination cost versus code length versus information leakage — rather than declaring one approach universally "best."

Common mistakes: assuming random generation "never" collides at production scale without doing the birthday-paradox math, or assuming a counter is fine without acknowledging the single-writer bottleneck it creates.

Q4 How would you generate IDs across many application servers without funneling every request through one coordinator?#

Short answer: Give every node an identity — a worker ID assigned at startup, from a range leased from a coordinator, from pod ordinal in Kubernetes, or derived from the host — and let each node generate IDs independently using that identity plus a local timestamp and sequence, which is exactly the Snowflake pattern; the alternative is a central service that hands out pre-allocated ID ranges (for example, blocks of 10,000 IDs at a time) so nodes rarely need to talk to it.

Range allocation is the simpler retrofit onto an existing auto-increment scheme: a node asks a coordinator (or a dedicated row in the database, updated with an atomic increment) for the next unused block, then serves IDs from that block locally until it runs out and asks for another. This trades a small amount of ID-space waste — if a node crashes mid-block, those remaining IDs are never used — for near-zero coordination overhead in the common case. Worker-ID-based generation (classic Snowflake, or Guid.CreateVersion7() combined with your own shard tagging if you need routing information encoded) removes the coordinator from the hot path entirely; the only coordination needed is a one-time, low-frequency assignment of worker IDs, which can be as simple as a Kubernetes StatefulSet's stable pod ordinal or a lease acquired from a distributed lock at startup. The failure mode to design around in both approaches is clock skew: if a node's clock jumps backward, a purely timestamp-based scheme can generate an ID that collides with or sorts behind one it already issued, so production Snowflake implementations either refuse to generate IDs while the clock is behind its last-seen value or fold in enough randomness to make that scenario harmless.

What interviewers look for: recognition that "no coordination" is a spectrum, not absolute — range leasing versus fully independent generation trade a small amount of waste or setup complexity for eliminating an ongoing bottleneck — plus awareness of the clock-skew failure mode.

Q5 What storage would you choose for the URL mappings, and how would you model the data?#

Short answer: The access pattern is almost entirely single-row point lookups by short code, which both a relational table with a unique index on the code and a key-value or wide-column store handle well; pick a relational store (SQL Server, PostgreSQL) if you want strong consistency and simple operational tooling at moderate scale, and a horizontally partitioned key-value store if you need to scale writes and reads past what a single relational primary can sustain.

A minimal schema needs the short code as the primary key, the target URL, creation and expiration timestamps, and an owner reference if links are attached to accounts; because every read is a point lookup, there's no need for complex joins or secondary indexes beyond perhaps one on the owner for a "my links" page. A single well-tuned relational instance with read replicas comfortably serves tens of thousands of reads per second for this shape of query, and it gives you transactions for the create-with-custom-alias path (insert, fail on unique-constraint violation, return a conflict). Past that point, or when you need multi-region writes, a key-value store such as Azure Cosmos DB partitioned by short code spreads both reads and writes across partitions with single-digit-millisecond latency and lets you scale essentially linearly, at the cost of giving up cross-row transactions and needing to think explicitly about partition key design so no single partition becomes a hot spot. A hybrid is common in practice: relational or document storage as the system of record with rich metadata and admin tooling, and a cache (covered next) or a dedicated fast key-value tier in front of it for the redirect path itself, so the primary store's throughput ceiling rarely matters.

What interviewers look for: connecting the storage choice back to the point-lookup access pattern instead of defaulting to "NoSQL because it's a system design interview," and naming the actual trade-off (transactions and tooling versus horizontal write scale).

Q6 Design the caching layer for redirects. What do you cache, where, and how do you handle invalidation?#

Short answer: Cache the short-code-to-target-URL mapping itself, using a two-tier cache — an in-process layer for the hottest codes and a distributed layer (Redis) shared across instances — with a cache-aside pattern on the redirect path: check the cache, fall back to the store on a miss, populate the cache, and use tag-based invalidation so deactivating or updating a link doesn't require waiting out a TTL.

HybridCache (Microsoft.Extensions.Caching.Hybrid) is a good fit here because it manages exactly this two-tier setup for you and, critically, coordinates concurrent callers for the same key so a sudden spike of requests for a code that just fell out of cache triggers one store lookup instead of a stampede of identical ones — the single most important property for a redirect cache, since a viral link can otherwise turn a single cache miss into thousands of simultaneous database hits. Tag entries by link so an explicit deactivation or alias update can call RemoveByTagAsync instead of relying purely on expiration, and set the cache TTL to something comfortably longer than your acceptable staleness window since most links are read far more than they're modified.

C#
public sealed class RedirectService(HybridCache cache, IUrlRepository repository)
{
    public ValueTask<string?> ResolveAsync(string code, CancellationToken cancellationToken) =>
        cache.GetOrCreateAsync(
            $"redirect:{code}",
            ct => repository.FindTargetAsync(code, ct),
            new HybridCacheEntryOptions { Expiration = TimeSpan.FromHours(6) },
            tags: ["redirect"],
            cancellationToken: cancellationToken);
}

For more on the primitive and its tag-based invalidation model, see Caching in .NET and Output Caching, HybridCache and Rate Limiting in ASP.NET Core.

What interviewers look for: naming stampede protection specifically — it's the detail that separates someone who's operated a cache under a real traffic spike from someone repeating "just add a cache" as a platitude.

Common mistakes: treating cache invalidation as "just set a short TTL" without considering that a viral link needs its cache entry to survive updates gracefully, not expire and re-stampede every few minutes.

Q7 Should a redirect return HTTP 301 or 302, and why does that choice matter at scale?#

Short answer: Use 302 Found (or 307 if you need to preserve the request method) as the default: it isn't cached by browsers or intermediate proxies, so every click reliably reaches your service, which is what makes click analytics, link deactivation and target updates actually work; a 301 Moved Permanently gets cached client-side, cutting your redirect traffic dramatically, but at the cost of losing visibility into repeat clicks and the ability to change or revoke a link once a client has cached it.

This is a real, not academic, trade-off, and a strong answer states it as one instead of picking a side by default. A 301 is semantically "this resource has permanently moved," and browsers and CDNs take that literally — they'll satisfy future requests for that code straight from their own cache without ever contacting your service again, which is fantastic for reducing origin load but means you never see those subsequent clicks, can't retroactively deactivate an abused link, and can't repoint the code even if the destination changes. A 302 is semantically "temporarily elsewhere," so clients treat every click as a fresh request, hitting your redirect service (and therefore your cache and analytics pipeline) every time — the small added load per click is the price of retaining control and visibility, and it's why most production link shorteners default to 302 despite the extra origin traffic, then absorb that traffic with the caching layer from the previous question rather than by delegating caching to the client. A middle ground some systems use is 302 plus a short, explicit Cache-Control: private, max-age=60 header, trading a little analytics fidelity for a meaningful cut in redirect volume during a traffic spike, while keeping the link fully revocable within that window.

What interviewers look for: the specific mechanism (client/proxy caching semantics of 301 versus 302) rather than a memorized rule, and the connection back to analytics and revocability as the reasons production systems favor 302.

Common mistakes: stating that "301 is faster" without explaining why, or picking 301 for a generic shortener without noticing it silently breaks click analytics and link deactivation.

Q8 How would you build the click-analytics pipeline without slowing down the redirect hot path?#

Short answer: Never write analytics synchronously on the request that serves the redirect; instead, record a lightweight event (code, timestamp, coarse client metadata) to an in-memory bounded queue or a message broker and return the redirect immediately, letting a separate background consumer batch, aggregate and persist the events.

For a single-instance or moderate-scale deployment, a bounded System.Threading.Channels.Channel<T> written to by the redirect handler and drained by a hosted BackgroundService is enough: the redirect path does a non-blocking TryWrite and returns, and the background consumer batches inserts to keep the analytics store's write amplification down. That design loses in-flight events on a process crash, which is often an acceptable trade for click analytics (unlike the ledger data in a payments system), but if you need durability across restarts or want a fully horizontally scaled redirect tier, publish the click event to a message broker such as Azure Service Bus or Kafka instead, and let one or more independent consumers aggregate into a time-series or pre-aggregated counter store rather than writing one row per click at high volume — a counter keyed by (code, hour) incremented via a distributed cache or a write-optimized store scales far better than an ever-growing click-events table that every dashboard query has to scan.

What interviewers look for: the non-negotiable rule that the redirect response must not wait on the analytics write, and a sensible answer to "what if the process crashes before the event is durably recorded" that matches the stated durability requirement instead of over-engineering it.

Follow-up questions:

  • How would you compute unique-visitor counts without storing every raw event forever?
  • How would you detect and filter bot traffic before it pollutes the analytics?

Q9 What abuse and security controls does a public URL shortener need?#

Short answer: Rate limit link creation per user or IP, validate submitted URLs to block obvious open-redirect and self-referencing loops, screen targets against a URL-reputation or safe-browsing style check before or shortly after creation, and require authentication (or a proof-of-work/CAPTCHA step for anonymous use) so abusive accounts can be identified and revoked rather than only individual links.

Rate limiting belongs on the creation endpoint specifically, since that's the path an attacker automates to mass-generate phishing links; ASP.NET Core's Microsoft.AspNetCore.RateLimiting middleware supports exactly this with a token-bucket limiter, which is a better fit than a fixed window here because it tolerates short legitimate bursts (a user pasting several links at once) while still capping sustained abuse.

C#
builder.Services.AddRateLimiter(options =>
{
    options.AddTokenBucketLimiter("url-creation", limiterOptions =>
    {
        limiterOptions.TokenLimit = 20;
        limiterOptions.TokensPerPeriod = 20;
        limiterOptions.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
        limiterOptions.QueueLimit = 0;
    });
});

app.MapPost("/api/urls", CreateUrlAsync).RequireRateLimiting("url-creation");

Beyond rate limiting, validate that the target URL isn't pointing back at your own redirect domain (an open-redirect chain an attacker can use to disguise a malicious final destination behind a trusted-looking short link) and isn't on a known-malicious list; because reputation lists change after the fact, plan for a re-scan job that can retroactively deactivate a link whose target was flagged after creation, not just a check at creation time. Anonymous, unauthenticated creation is the highest-risk surface — most production systems either require an account for anything beyond a small daily quota or add a proof-of-work or CAPTCHA step specifically to that path, because IP-based rate limiting alone is cheap for an attacker to route around.

What interviewers look for: treating abuse prevention as a system property with multiple layers (rate limiting, validation, reputation checks, identity) rather than a single silver-bullet control, and specifically naming the open-redirect risk, which is easy to miss.

Common mistakes: relying solely on IP-based rate limiting, which is trivially defeated by rotating source addresses, or only screening the target URL once at creation time instead of accounting for reputation data that arrives later.

Q10 How would you evolve this from a single-region service to a horizontally scaled, multi-region deployment?#

Short answer: Keep the API stateless behind a load balancer so instances scale horizontally without coordination, use a distributed ID generation scheme (worker IDs or Guid.CreateVersion7()-style, from the earlier question) so no region depends on another to mint IDs, replicate or shard the data store per region, and accept eventual consistency for link propagation across regions in exchange for low, region-local latency on both reads and writes.

Statelessness is what makes the API tier trivial to scale — any instance in any region can serve any request as long as it can reach the data and cache tier, so add capacity by adding instances behind the load balancer and let health checks handle the rest. The data tier is where the real design decisions live: a globally distributed store (or an active-active relational setup with per-region read replicas and asynchronous replication) lets each region serve reads locally, while writes either go to a region-local partition (if IDs are already partitioned by worker/region, there's no cross-region write conflict to resolve) or to a designated primary with asynchronous propagation, accepting a short window where a freshly created link isn't yet resolvable from every region. The cache tier scales the same way — a distributed cache instance per region, independently warmed, rather than one global cache serving cross-region traffic at unacceptable latency. Container orchestration (Kubernetes, or a .NET Aspire-modeled deployment for the local development and manifest-generation story) gives you the mechanics for rolling out stateless instances per region; the design work is almost entirely in getting the data and cache tiers to a shape where "region-local" is the common case and cross-region coordination is the rare one.

What interviewers look for: identifying that horizontal API scaling is close to free once the service is stateless, and that the actual hard problems move to the data tier — ID generation, replication lag and cache locality — rather than treating "add more servers" as the whole answer.

Quick-Fire Round#

QuestionAnswer
What read:write ratio is typical for a URL shortener?Roughly 100:1 to 1,000:1 reads to writes.
How many values does a 7-character base62 code address?About 3.5 trillion (62^7).
What .NET 9+ API generates a time-ordered UUID without a coordinator?Guid.CreateVersion7().
Which redirect status code keeps every click hitting your server?302 Found (or 307 to preserve method).
Which redirect status code gets cached by browsers and proxies?301 Moved Permanently.
What HybridCache feature prevents a cache-miss stampede?Coordinating concurrent callers so one factory call serves them all.
What ASP.NET Core rate limiter tolerates short bursts but caps sustained abuse?The token bucket limiter.
Why shouldn't analytics writes happen inline on the redirect path?They'd add latency to the highest-traffic request in the system.
What's the main risk of validating a target URL only at creation time?Reputation data that arrives later never triggers a retroactive check.
What makes an API tier trivial to scale horizontally?Statelessness — any instance can serve any request.

How to Prepare#

  • Practice stating the read:write ratio and back-of-envelope QPS numbers out loud in under two minutes; it should anchor every later decision.
  • Be ready to compare at least three ID-generation strategies with their coordination trade-offs, not just describe one.
  • Rehearse the 301 versus 302 answer as a mechanism-first explanation, since it's the question most candidates get right by luck rather than reasoning.
  • Have a concrete stampede-protection answer ready for the caching question — it's a strong signal of hands-on cache operation experience.
  • Know the difference between rate limiting, validation and reputation screening as three distinct layers of abuse prevention.
  • Practice sketching the stateless-API/sharded-data split so multi-region scaling doesn't turn into an unstructured list of buzzwords.