Most QR code services run redirects through their main application server. Here's why that's a terrible idea, and how I solved it with Cloudflare Workers for OwnQR.
The Problem
When someone scans a dynamic QR code, three things happen:
- The phone opens a URL (like
oq.link/abc123) - The server looks up where
abc123should redirect to - The user gets sent to the destination
If you run this through a Next.js/Vercel app, you're looking at:
- Cold starts: 500ms+ on serverless
- Single region: User in Tokyo, server in Virginia = 200ms latency
- Database query: Another 50-100ms
That's 750ms+ before the user sees anything. For a QR code scan (which people expect to be instant), that feels broken.
The Solution: Edge Redirects
I moved the redirect logic to a Cloudflare Worker. The entire lookup + redirect happens at the nearest edge node:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url)
const match = url.pathname.match(/^\/r\/([a-zA-Z0-9]+)$/)
if (!match) return new Response('Not Found', { status: 404 })
const slug = match[1]
const qrCode = await getDestination(slug, env)
if (!qrCode) return new Response('QR Code not found', { status: 404 })
// Log scan event in background (non-blocking)
ctx.waitUntil(logScanEvent(qrCode.id, request, env, request.cf))
// 302 redirect (not 301 -- allows URL changes)
return Response.redirect(qrCode.destination_url, 302)
},
}
Why 302 Instead of 301?
Dynamic QR codes let users change their destination URL anytime. A 301 (permanent redirect) gets cached by browsers -- if a user changes their link, returning visitors would still see the old destination. 302 ensures every scan hits the latest URL.
Free Geo Data with request.cf
Cloudflare gives you city, country, latitude, and longitude on every request via the request.cf object -- no external geo API needed:
async function logScanEvent(qrId: string, request: Request, env: Env, cf: any) {
const ua = request.headers.get('User-Agent') || ''
await fetch(`${env.SUPABASE_URL}/rest/v1/scan_events`, {
method: 'POST',
headers: {
apikey: env.SUPABASE_ANON_KEY,
Authorization: `Bearer ${env.SUPABASE_ANON_KEY}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
qr_id: qrId,
device_type: getDeviceType(ua),
os_type: getOS(ua),
ip_city: cf?.city || null,
ip_country: cf?.country || null,
}),
})
}
Non-Blocking Analytics with ctx.waitUntil()
The redirect fires immediately. Analytics logging happens in the background via ctx.waitUntil(). The user doesn't wait for the database insert.
Results
| Metric | Before (Next.js) | After (Workers) |
|---|---|---|
| Average redirect | ~750ms | ~40ms |
| Cold starts | Yes | None |
| Edge locations | 1 region | 300+ cities |
| Infra cost | Included in Vercel | ~$5/month |
Architecture
User scans QR -> Cloudflare Worker (edge) -> 302 redirect
| (async, non-blocking)
Log to Supabase
(device, city, country, timestamp)
The main app (Next.js on Vercel) handles QR code creation, dashboard, payments, and design customization. The Worker only handles redirects -- the one thing that needs to be globally fast.
Open Source
I've open-sourced the redirect worker: cloudflare-qr-redirect on GitHub
Zero npm dependencies -- just Cloudflare Workers built-in APIs + Supabase REST.
The Product
This architecture powers OwnQR -- a QR code generator where you pay $15 once instead of monthly subscriptions. If you're tired of QR code services charging $7-15/month for a redirect, give it a try.
Have questions about the Cloudflare Workers setup? Drop a comment below.
Top comments (0)