7 min read

First app where a new piece of infrastructure — Redis — earns its place. Two new hard problems: the redirect path is the hottest, most latency-sensitive path you'll build in this playbook, and it has to stay fast while still recording analytics on every click without slowing the user down.


1. Problem framing

Long URLs are unwieldy to share. A user wants to turn a long URL into a short, memorable one, share it anywhere, and later see how many people clicked it and roughly where from. The redirect itself has to be near-instant — every millisecond of added latency is a millisecond the visitor is staring at a blank tab before the real page loads.

2. PRD

Goal: Let a signed-in user shorten a URL and see click analytics for it; let anyone follow a short link with minimal added latency.

Non-goals:

  • Custom domains (short.co/x only, no bring-your-own-domain)
  • QR code generation
  • Detailed geo/device analytics beyond referrer + rough country
  • Link expiration reminders

User stories:

  • As a user, I want to paste a URL and get a short link, so I can share it.
  • As a user, I want to optionally pick a custom short code, so the link is memorable.
  • As a visitor, I want the short link to redirect me instantly.
  • As a user, I want to see click count and referrers for my links, so I know if my share worked.

Success metrics:

  • Redirect latency p99 under 50ms (cache hit path)
  • Every real click gets recorded — analytics undercount is a bug, not a rounding error

MVP cut line: create + redirect + click count + referrer breakdown. Custom domains and deep geo analytics are deferred.

3. Wireframes & UX flow

Screen inventory:

ScreenPurpose
DashboardList of my links, click counts, create-new form
Link analyticsTime-series clicks, referrer breakdown for one link
(Redirect — not a screen)GET /:code — no UI, just a 302

The redirect flow is deliberately drawn as "out of app" — it's not a page a user navigates within our UI, it's an HTTP redirect served to anyone, signed in or not. That distinction matters for the HLD below: two very different traffic patterns share this app.

4. HLD

Two request types, two different jobs: the dashboard is normal low-traffic CRUD (same shape as apps 1–2). The redirect path is read-dominated, latency-critical, and public — that's the one that needs a cache in front of Postgres.

5. Data model / ERD

Index: LINK(short_code) unique — this is the lookup for every redirect, and it's also the cache key.

6. API design + flow mapping

MethodPathBodyAuth
POST/api/links{ longUrl, customCode? }required
GET/api/links—required (own links)
GET/api/links/:id/analytics—required, must own
GET/:code— (redirect)none

Flow: create a short link

Flow: redirect — the hot path

The visitor gets the 302 the instant the URL is known — the click-event insert happens after the response is already sent, using Next.js's after() hook, not a blocking await. This is the whole point of the app: the analytics write must never be on the critical path of the redirect.

7. LLD

Module breakdown:

code
app/[code]/route.ts        — the redirect handler (hot path)
app/api/links/route.ts     — create/list
app/api/links/[id]/analytics/route.ts
lib/shortcode.ts           — random base62 generator + collision retry
lib/cache.ts               — Redis get/set wrapper

Edge cases worth naming explicitly:

  • Code collision — random 7-char base62 has a huge space, but at scale collisions aren't impossible; rely on the DB unique constraint to detect it and retry generation, don't pre-check-then-insert (that's a race in itself — two requests could both pass the pre-check for the same code).
  • Expired link — if expires_at has passed, don't redirect: serve a 410 Gone page instead, and cap the cache TTL at min(default_ttl, time_until_expiry) so an expired link doesn't keep redirecting from a stale cache entry.
  • Fire-and-forget done wrong — an unawaited promise in a serverless function can be frozen/killed the instant the response is sent, silently dropping the click-event insert. Use the platform's actual post-response hook (after()), not a bare fetch(...) without await.
  • Open-redirect-style abuse — reject javascript:/data: URL schemes on creation; only http(s):// is accepted as a target.

8. NFRs

  • Performance: redirect p99 < 50ms on cache hit; cache hit rate is a metric worth tracking explicitly, not just latency, because latency looks fine right up until the cache starts missing under load.
  • Security: rate-limit link creation per user/IP (this service can otherwise become a phishing-link generator); scheme validation as above.
  • Observability: log cache hit/miss ratio, redirect latency histogram, and click-event insert failures (these fail silently by design since they're post-response — silent failure still needs to be logged).

9. ADRs

code
ADR-1: Random short codes, not sequential auto-increment
Context: sequential ids (1, 2, 3…) let anyone enumerate every link ever
         created by incrementing the URL.
Decision: random base62 code, collision detected via unique constraint.
Consequence: a rare retry-on-insert cost, in exchange for not leaking the
             existence of every other user's links.

ADR-2: Redis cache-aside in front of the redirect lookup
Context: this is the first app in the playbook where a cache is actually
         justified — the redirect path is public, high-read, and
         latency-critical in a way apps 1–2 never were.
Decision: cache short_code → long_url in Redis, populate on miss.
Consequence: an extra piece of infrastructure, taken on specifically
             because this app's NFRs (p99 < 50ms) demand it — not by default.

ADR-3: Log clicks via a post-response hook, not a blocking write
Context: recording a click shouldn't cost the visitor redirect latency.
Decision: use Next.js after() to insert the click_event once the redirect
         response has already been sent.
Alternatives: await the insert (simple, but adds latency to every
              redirect); true fire-and-forget fetch (unsafe — can be
              killed mid-flight by the serverless runtime).
Consequence: click counts can very rarely under-count if the function is
             hard-killed before after() completes — an accepted tradeoff
             for keeping the redirect itself fast.

ADR-4: 302 (temporary) redirect, not 301 (permanent)
Context: browsers cache 301s aggressively and may never hit our server
         again for a repeat click from the same browser.
Decision: always respond 302, even though the destination never changes.
Consequence: slightly less HTTP-cache-friendly, but every click — including
             repeat clicks from the same visitor — actually reaches our
             server and gets counted. Losing that would defeat the app's
             whole purpose.

10. Deploy plan

  • Environments: local (Postgres + local Redis via Docker), Preview, Production (Postgres + managed Redis, e.g. Upstash).
  • CI: typecheck, lint, plus a load-style test asserting redirect latency stays under budget with a warm cache.
  • Definition of done: create a link, follow it (redirects fast), see the click show up in analytics within a few seconds, deployed.
  • Rollback: Vercel instant rollback; cache is disposable (never the source of truth) so no data-migration risk there.

11. Retro — "at 10x"

At 10x traffic, the real risk isn't average load, it's a single link going viral — a hot key hammering one Redis entry and one Postgres row far harder than everything else combined. Redis handles a hot key fine on its own (that's what it's for), but the click-event insert volume for that one link could start to matter even with after(), since Postgres row inserts aren't free at very high concurrency on a single link. At that point I'd move click logging off a direct insert-per-click and onto an append-only event stream with batched aggregation — which is exactly the queue-based pattern app 7 introduces properly. Here, it's enough to know the ceiling exists and why.