← Back to blog

Building a Lightweight URL Shortener on Cloudflare Workers, KV, and D1

#cloudflare#workers#kv#d1#side-project

Most URL shorteners are a single lookup table and a redirect — until you want analytics, and suddenly you’re running a database, a caching layer, and a dashboard just to answer “which link got clicked, from where, and via which campaign?” I built one as a side project to see how lightweight that whole stack could be if it lived entirely on Cloudflare’s edge.

The shape of the problem

A URL shortener has two very different workloads hiding inside it:

  • Reads are everywhere and latency-sensitive. Every redirect is on the critical path of someone’s click — it needs to resolve in single-digit milliseconds, globally, and it happens far more often than links are created.
  • Writes are rich and can be slower. Creating a link, and logging a click’s metadata (UTM params, referrer, path, rough geography), doesn’t need to block anything — it just needs to land reliably.

That split is what shaped the architecture: one storage layer optimized purely for fast reads, and one optimized for structured, queryable writes.

Two-tier storage: KV for hot paths, D1 for everything else

Workers KV — the hot path
Key-value cache of slug → destination URL, replicated to the edge. Reads for recently or frequently used slugs never leave the region.
D1 — the source of truth
SQLite at the edge. Holds every link's full record plus every click event: UTM params, search params, referrer path, coarse demographic/geo data.

The read path checks KV first; only a cache miss touches D1 — and that miss backfills KV so the next request for that slug is fast too:

STEP 1
A request hits /abc123; the Worker checks KV for that slug.
STEP 2A · CACHE HIT
KV returns the destination URL directly. The Worker issues a 302 redirect immediately — no database touched at all.
STEP 2B · CACHE MISS
The Worker queries D1 for the slug, writes the result back into KV for next time, then redirects.
STEP 3 · ALWAYS, IN THE BACKGROUND
The click event (UTM params, referrer, path, geo) is logged into D1 asynchronously — it never delays the redirect response.
export default {
  async fetch(request, env, ctx) {
    const slug = new URL(request.url).pathname.slice(1);

    // 1. Hot path: check the edge cache first
    let destination = await env.LINKS_KV.get(slug);

    // 2. Cache miss: fall back to D1, then backfill KV for next time
    if (!destination) {
      const row = await env.DB
        .prepare("SELECT destination FROM links WHERE slug = ?")
        .bind(slug)
        .first();

      if (!row) return new Response("Not found", { status: 404 });

      destination = row.destination;
      ctx.waitUntil(env.LINKS_KV.put(slug, destination, { expirationTtl: 86400 }));
    }

    // 3. Log the click without blocking the redirect
    ctx.waitUntil(logClick(request, env, slug));

    return Response.redirect(destination, 302);
  },
};

The ctx.waitUntil() calls are what make this work without a slowdown: the Worker returns the redirect the moment it knows the destination, while KV writes and click logging finish after the response has already been sent.

What gets logged per click

D1 is a real relational database, so a click isn’t just a counter — it’s a row with everything needed to slice the data later:

CREATE TABLE links (
  slug TEXT PRIMARY KEY,
  destination TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE TABLE clicks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug TEXT NOT NULL,
  clicked_at TEXT NOT NULL,
  referrer TEXT,
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  country TEXT,
  city TEXT
);

country and city come straight off the request’s cf object that Cloudflare attaches to every incoming request at the edge — no third-party geo-IP lookup needed.

The analytics panel: just SQL

Rather than bolt on a BI tool, the panel is a small admin page that runs plain SQL against D1 and renders the result as a table — because D1 already is queryable SQLite, a dashboard doesn’t need to be more than that:

-- Top links by clicks in the last 7 days
SELECT l.slug, l.destination, COUNT(c.id) AS clicks
FROM links l
JOIN clicks c ON c.slug = l.slug
WHERE c.clicked_at >= datetime('now', '-7 days')
GROUP BY l.slug
ORDER BY clicks DESC
LIMIT 10;

-- Where traffic is coming from
SELECT utm_source, COUNT(*) AS clicks
FROM clicks
WHERE utm_source IS NOT NULL
GROUP BY utm_source
ORDER BY clicks DESC;

No separate analytics service, no export pipeline, no second database to keep in sync — the same store that serves redirects on a cache miss answers every reporting question directly.

The result

A URL shortener that’s genuinely lightweight end to end:

  • Redirects resolve at the edge, close to the user, with most requests never touching a database.
  • Analytics are optional by design — logging is fire-and-forget, so if it’s ever slow or disabled, redirects are completely unaffected.
  • The whole stack is three primitives — Workers, KV, D1 — with no servers to patch, no separate cache cluster, and no BI tool to license.

The general lesson

When a workload has a clear hot path and a clear cold path, don’t force them through the same storage layer. Here, KV earns its keep purely on read latency for the 90% case, while D1 earns its keep as the durable, queryable source of truth for the 10% case and for everything analytical. Picking the right tool per access pattern — instead of one database trying to be good at both — is what kept this simple enough to be a weekend project instead of a platform.