How We Cut Redirect Latency 10x by Putting KV in Front of D1

How We Cut Redirect Latency 10x by Putting KV in Front of D1

April 20, 2026

In our previous post we argued D1 over KV for Shrtnr. The relational model won because we needed queryable analytics, not just slug-to-URL lookups.

That reasoning still holds. But it turned out to be only half the architecture we ended up wanting. Every redirect was paying a round-trip from the edge to a single-region D1 primary. For a tool whose whole job is getting out of the way fast, that's the wrong latency budget.

So we added KV as a write-through cache in front of it. D1 is still the source of truth; KV handles the redirect hot path. This is the piece we'd have shipped from day one if we'd been honest about what a redirect needs versus what the rest of the app needs.

The bottleneck: D1 is a single-region database

Cloudflare Workers run at 300+ edge locations. D1 does not. A D1 database has a single primary, sitting in one data center. When a Worker in Jakarta handles a redirect, the SQL query crosses whatever geographic distance separates that edge from the D1 primary, and the round-trip happens before the 301 can be sent.

SQL duration itself is fast: Cloudflare documents indexed reads at sub-millisecond. But SQL duration is not request duration. The Worker opens a connection, sends the query, waits for the response, and parses it. Network latency between the edge and the D1 primary dominates. Realistic numbers: 30–200ms per redirect depending on geography.

For click tracking we already had the right pattern. Click writes run in the background via ctx.waitUntil() after the redirect has been sent, so recording analytics never blocks the visitor. But slug resolution, answering "where does this short link actually go?", happened on the request path. And that request path owned the D1 round-trip.

The fix: KV as an edge cache for slug resolution

Cloudflare KV is a key-value store that caches values at whichever edge location reads them. First read from a given region pulls from central storage; subsequent reads from that same region serve from the local cache at sub-millisecond to low-single-digit-millisecond latency. No cross-region round-trip.

The change: store slug-to-URL mappings in KV. Use KV as the first lookup on every redirect. Fall back to D1 only on a cache miss. Keep D1 as the source of truth for everything else: link management, click analytics, admin operations.

What KV stores per slug

Each slug is the raw KV key, no prefix, no namespace. The value is a small JSON blob, roughly 80–120 bytes:

{
  "url": "https://destination.com/page",
  "disabled_at": null,
  "expires_at": null
}

That's enough to resolve the redirect at the edge. One KV read replaces one D1 query.

KV supports time-to-live on keys, which was tempting for expired links. We didn't use it. Links can be re-enabled. A user sets expires_at back to null, and the slug needs to resolve again. If the KV key had expired and been deleted, re-enabling the link would mean reconstructing the entry from D1. Storing the timestamp inside the value and checking it in code keeps every state transition simple and reversible.

For the same reason, we keep disabled slugs in KV instead of deleting them. If a disabled slug is missing from KV, the redirect handler falls back to D1 on every request. A bot hammering a disabled slug would generate unbounded D1 traffic. Keeping disabled_at in the KV value lets the handler return 404 at the edge without touching D1.

The redirect path: before and after

Before, every redirect was the same sequence:

Request arrives at edge
  → D1 query (cross-region round-trip)
  → Check disabled / expired
  → 301 redirect
  → waitUntil: record click in D1

Every redirect paid the D1 latency cost. No caching layer.

After, D1 only gets touched if KV misses:

Request arrives at edge
  → KV read (local edge, sub-millisecond for cached keys)
  → [KV hit] Check disabled / expired → 301 redirect
  → [KV miss] D1 fallback → populate KV → 301 redirect
  → waitUntil: record click in D1

On a KV hit, D1 is never touched for the redirect. Click recording still goes to D1 via ctx.waitUntil() and still doesn't block the response. The click write happens after the visitor has already been sent to the destination.

Write-through: keeping KV and D1 consistent

KV is eventually consistent. Writes propagate globally within roughly 60 seconds. For a URL shortener, that's an acceptable window. It's the same order of magnitude as DNS propagation, and strictly weaker than the delay we tolerate for most admin actions. A newly created link might not redirect at every edge for up to a minute. A disabled link might still redirect for up to a minute at some locations. Neither is a correctness violation; both are bounded and short.

To keep that window as tight as KV itself allows, every mutation writes through synchronously. D1 gets written first. It's the source of truth. Then KV is updated before the API response is sent. No waitUntil() on the cache write. If the KV put fails, the request fails. If it succeeds, the consistency clock starts at the point of the D1 commit, not minutes later when some async job catches up.

Every mutation that changes a slug's state (create, update, disable, enable, delete) goes through one service layer. Admin API, public API, and the MCP tools our AI workflows call all go through the same functions. Cache consistency is handled in one place, not sprinkled across request handlers.

Read-through: zero-downtime migration

We didn't bulk-import existing slugs into KV when we shipped the cache. There's no migration script, no "warm the cache" deploy step. Instead, the redirect handler populates KV on miss. The first request to any pre-existing slug pays the D1 cost and writes the entry into KV. Every request after that hits the cache.

Operationally this is the lowest-risk change we could have made. Production traffic warms the cache as it arrives. There's no moment where the system is mid-migration and serving stale or missing data. Rollback is trivial: remove the KV binding and the handler keeps using the D1 fallback path it has always had.

Performance impact

On a cache hit, the slug resolution step drops from 30–200ms (D1 round-trip) to 1–5ms (KV read at the local edge). That's roughly a 10x to 50x latency reduction, depending on how far the edge location is from the D1 primary. For the visitor, it's the gap between clicking a link and seeing the destination page start loading.

D1 query volume drops with it. KV's default cache TTL is 60 seconds. For any slug accessed more than once a minute from the same region, the hit rate approaches 100%. For a slug accessed once from many regions, the worst case is one D1 query per Cloudflare location: roughly 300 queries to warm everything, versus one query per click. Conservatively: 90–99% fewer redirect queries against D1.

What stays on D1

KV took over one specific job: answer "where does this slug point?" at the edge. Everything else still runs on D1.

Click recording still writes to D1 via ctx.waitUntil(). Every analytics query still runs as a D1 aggregation: clicks by country, referrer, device, browser, OS, and time range. Every admin operation (creating links, editing destinations, managing API keys, searching) still goes through D1. KV cannot answer "show me the top ten referrers for this link in the last 30 days." It was never going to.

We rejected KV-only, not KV-as-cache

If you read our previous architecture post carefully, the argument was narrower than it sounded. KV "falls apart" once you need analytics, because KV can't do aggregation. That's still true. What we didn't say explicitly, and what the write-through cache makes explicit, is that "can't do analytics" is a statement about KV as a database, not KV as a cache.

For the redirect hot path, KV is the right tool. It's globally replicated, cached at the edge, optimized for read-heavy workloads with sparse writes. That describes URL redirects almost perfectly. For analytics, D1 is still the right tool: relational, queryable, indexable across dimensions. Using each for what it does best is better than picking one and compromising.

The first post's conclusion still stands: "D1 is the tool that fits" for a product that needs both fast lookups and queryable analytics. What's changed is that we added a second tool for the first half of that job. Fast lookups don't need SQL; they need a cache that lives at the edge.

What's next

That covers the architecture as it stands now. The production-ops post we promised is still coming. Three authentication systems in a single Worker, one-click deployment, the TypeScript SDK, and what this costs to run. The cache layer doesn't change any of that; it just makes the redirect path quieter.

About Oddbit

We're a senior software development team based in Bali, Indonesia, with roots in Sweden. We build custom software for startups and scale-ups: web applications, mobile apps, SaaS platforms, and the integrations that connect them. Our clients span the Nordics, Europe, and Southeast Asia.

Shrtnr is one of several open-source tools we've built and released. Others include Tanam (a Firebase CMS with AI content generation), Flutter packages with about hundred thousand downloads on pub.dev, and Firebase Genkit plugins for AI model integration.

If you're working through a technical architecture decision, evaluating edge computing for your product, or building something from scratch, talk to us. We'd rather show you our work than tell you about it.

Google Cloud Partner

Tell Us About Your Project