Why I Built Another URL Shortener
Every URL shortener I tried had the same problem: either bloated enterprise UX or crippled free tiers that made the tool useless for real work.
Bitly gives you 10 free links. Ten. TinyURL has no analytics. Most alternatives are either dead, slow, or both.
So I built go2.gg — a fast, developer-friendly link shortener running entirely on Cloudflare's edge. Sub-10ms redirects. 50 free links/month. Full analytics. API-first.
Here's how the architecture works and why Cloudflare Workers + KV turned out to be the perfect stack for this.
The Architecture: Edge-First Everything
The core insight: a URL shortener is fundamentally a key-value lookup. Someone hits go2.gg/xyz, you look up xyz, return a 301 redirect. That's it.
Cloudflare KV is literally a globally distributed key-value store with sub-millisecond reads at the edge. It's almost too perfect.
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Browser │────▶│ Redirect Worker │────▶│ Cloudflare KV │
│ go2.gg/xyz │ │ (edge, <10ms) │ │ (link lookup) │
└─────────────┘ └──────────────────┘ └─────────────────┘
│
┌──────────────┐
│ Queues │
│ (click log) │
└──────────────┘
Two Workers handle everything:
- Redirect Worker — the hot path. KV lookup → 301 redirect. That's the entire function.
- API Worker — handles link creation, analytics queries, auth, billing.
The Redirect Worker: 15 Lines That Matter
export default {
async fetch(request, env) {
const url = new URL(request.url);
const slug = url.pathname.slice(1);
if (!slug) return Response.redirect(env.DASHBOARD_URL, 302);
const link = await env.LINKS_KV.get(slug, "json");
if (!link) return new Response("Not found", { status: 404 });
// Fire-and-forget click tracking
await env.CLICK_QUEUE.send({
slug,
timestamp: Date.now(),
country: request.cf?.country,
device: request.headers.get("user-agent"),
referrer: request.headers.get("referer"),
});
return Response.redirect(link.destination, 301);
},
};
Key decisions:
- KV for reads, D1 for writes. KV is eventually consistent but insanely fast for reads. D1 (SQLite at the edge) stores the canonical link data and analytics aggregates.
- Fire-and-forget analytics via Queues. The redirect doesn't wait for analytics to complete. Click data goes into a Cloudflare Queue, gets processed async by a consumer Worker that batches inserts into D1.
- 301 vs 302. We default to 301 (permanent) for SEO juice passthrough, but support 302 for links that might change destinations.
Click Analytics Without a Database Bottleneck
The naive approach: INSERT a row for every click. At scale, that's a firehose aimed at your database.
Instead, we use Cloudflare Queues as a buffer:
// Queue consumer — processes clicks in batches
export default {
async queue(batch, env) {
const clicks = batch.messages.map((m) => m.body);
// Batch insert into D1
const stmt = env.DB.prepare(
`INSERT INTO clicks (slug, country, device, referrer, clicked_at)
VALUES (?, ?, ?, ?, ?)`
);
await env.DB.batch(
clicks.map((c) =>
stmt.bind(c.slug, c.country, c.device, c.referrer, c.timestamp)
)
);
// Update aggregate counters
for (const c of clicks) {
await env.DB.prepare(
`UPDATE links SET click_count = click_count + 1
WHERE slug = ?`
).bind(c.slug).run();
}
},
};
Queues batch messages automatically (up to 100 per invocation), so even under heavy traffic, the database sees controlled batch writes instead of individual INSERTs.
The KV Sync Problem
Here's the gotcha: when someone creates or updates a link via the API, the D1 write is instant, but KV propagation takes up to 60 seconds globally.
Our solution:
// On link creation/update
async function createLink(env, slug, destination) {
// Write to D1 (source of truth)
await env.DB.prepare(
`INSERT INTO links (slug, destination_url, user_id)
VALUES (?, ?, ?)`
).bind(slug, destination, userId).run();
// Write to KV (fast reads)
await env.LINKS_KV.put(
slug,
JSON.stringify({ destination, created: Date.now() }),
{ expirationTtl: 86400 * 365 } // 1 year TTL
);
return { slug, shortUrl: `https://go2.gg/${slug}` };
}
We write to both simultaneously. KV propagation might lag on edge nodes the user hasn't hit yet, but the creating user's nearest PoP gets it immediately.
QR Codes: Server-Side, Not Client-Side
Every link gets a free QR code at go2.gg/qr/{slug}. Generated server-side with the qrcode package:
import QRCode from "qrcode";
app.get("/api/v1/links/:id/qr", async (c) => {
const link = await getLink(c.env, c.req.param("id"));
const svg = await QRCode.toString(link.shortUrl, {
type: "svg",
margin: 2,
color: { dark: "#000", light: "#fff" },
});
return new Response(svg, {
headers: { "Content-Type": "image/svg+xml" },
});
});
Why server-side? Consistent rendering, cacheable at the edge, and works in emails/PDFs where client-side JS doesn't run.
What I'd Do Differently
1. Start with D1 only, add KV later. D1 reads are fast enough for most traffic levels. KV optimization matters at 10K+ RPM — premature optimization otherwise.
2. Use Durable Objects for real-time counters. Our current approach has a small race condition window on counter updates. Durable Objects would give us strongly consistent counters per link.
3. Custom domains from day one. The most requested feature, and architecturally it's just Cloudflare for SaaS — should have wired it in from the start.
The Numbers
- Redirect latency: 4-8ms globally (KV read + response)
- Cold start: ~2ms (Workers have no cold start problem)
- Cost at 1M redirects/mo: ~$5 (Workers free tier covers most of it)
- Total infra cost: Under $25/mo for the full stack
Cloudflare's pricing is absurdly good for this use case. The free tier alone handles 100K redirects/day.
Try It
go2.gg is live. Free tier gets you 50 links/month with full analytics, QR codes, and API access. No credit card required. Pro starts at $7/month with 2K links and geo targeting.
The API is straightforward:
curl -X POST https://api.go2.gg/api/v1/links \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"destinationUrl": "https://your-long-url.com", "slug": "my-link"}'
If you're building something that needs link shortening, the API is free to use. Happy to answer questions about the architecture in the comments.
Top comments (0)