When I started building FlowLink, I knew redirect latency would be critical. Nobody wants to wait 500ms for a shortened link to resolve. The gold standard is under 50ms—fast enough that users don’t even notice the redirect.
After evaluating several approaches (Rails with Redis, dedicated Go service, AWS Lambda@Edge), I landed on Cloudflare Workers. Here’s why, and how I built it.
Why Cloudflare Workers?
Three things made Workers the obvious choice:
-
Global distribution by default. Workers run on Cloudflare’s edge network—over 300 data centers worldwide. Your code runs close to your users, no configuration required.
-
KV storage is perfect for this use case. Cloudflare KV is a globally distributed key-value store with millisecond reads. It’s eventually consistent for writes, but redirects are read-heavy.
-
The free tier is generous. 100,000 requests/day and 1GB of KV storage. For a new product, that’s plenty of runway.
The Architecture
User clicks shortened link
↓
Cloudflare Worker (closest edge location)
↓
KV lookup (slug → destination URL)
↓
302 redirect with tracking pixel
↓
User lands on destination
The whole flow takes 10-50ms depending on location. Compare that to a traditional setup where the request might travel to a single origin server and back.
The Worker Code
Here’s the core redirect logic, simplified from the production implementation:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Extract slug from URL path
const url = new URL(request.url);
const slug = url.pathname.slice(1); // Remove leading /
if (!slug) {
return Response.redirect('https://flowlink.ing', 302);
}
// Look up the link in KV with edge caching (5-minute TTL)
const linkDataJson = await env.LINKS.get(slug, { cacheTtl: 300 });
if (!linkDataJson) {
return new Response('Link not found', { status: 404 });
}
// Parse link data
const linkData = JSON.parse(linkDataJson);
// Capture click metadata without blocking the redirect
const clickData = {
slug,
ip_address: request.headers.get('CF-Connecting-IP'),
user_agent: request.headers.get('User-Agent'),
referrer: request.headers.get('Referer'),
country_code: request.headers.get('CF-IPCountry'),
clicked_at: new Date().toISOString(),
};
// Send analytics webhook asynchronously (doesn't block 302 redirect)
ctx.waitUntil(
fetch('https://flowlink.ing/webhooks/analytics', {
method: 'POST',
body: JSON.stringify(clickData),
headers: { 'Content-Type': 'application/json' },
})
);
// Return 302 redirect immediately
return Response.redirect(linkData.destination_url, 302);
},
};
KV Data Structure
Each link is stored as a JSON object in KV with the slug as the key:
{
"link_id": "link_abc123xyz",
"account_id": "acc_user789",
"slug": "my-link",
"domain": "flowlink.ing",
"destination_url": "https://example.com/long-page-url?utm_source=twitter",
"created_at": "2025-11-15T10:30:00Z",
"utm_templates": [
{
"platform_domains": ["twitter.com"],
"utm_source": "twitter",
"utm_medium": "social"
}
],
"geo_rules": [
{
"country_code": "GB",
"destination_url": "https://example.co.uk/landing"
}
],
"ab_test": {
"id": 42,
"status": "active",
"variants": [
{ "id": "v1", "name": "Control", "destination_url": "https://example.com/a", "weight": 50 },
{ "id": "v2", "name": "Variant", "destination_url": "https://example.com/b", "weight": 50 }
]
}
}
Each link lookup is O(1) because KV uses the slug as a direct key. The production implementation also handles evolution rules (time-based destination changes), geographic routing, and A/B testing—all evaluated at the edge for sub-50ms response times.
Handling Click Analytics
The key challenge: track clicks without blocking the redirect. Cloudflare Workers’ ctx.waitUntil() solves this by letting you spawn background tasks that run after the response is sent:
// Blocking - slows down redirect response
await sendAnalytics(clickData);
// Non-blocking - returns response immediately, webhook sends in background
ctx.waitUntil(sendAnalytics(clickData));
The production implementation captures click metadata and sends it via webhook to Rails:
function captureClickEvent(request: Request, linkData: LinkData): ClickEventData {
return {
event_id: crypto.randomUUID(),
link_id: linkData.link_id,
slug: linkData.slug,
clicked_at: new Date().toISOString(),
ip_address: request.headers.get('CF-Connecting-IP'),
user_agent: request.headers.get('User-Agent'),
referrer: request.headers.get('Referer'),
country_code: request.headers.get('CF-IPCountry'),
device_type: detectDeviceType(request.headers.get('User-Agent')),
ab_test_id: abTestResult.abTestId,
ab_variant_id: abTestResult.selectedVariant?.id,
};
}
The Rails app receives these webhooks and persists click data to the database. This architecture gives you:
- Sub-50ms redirects - Analytics don’t block the response
- Detailed attribution - Device type, referrer, country, A/B test variant
- Batch processing - Rails aggregates clicks for reporting
CF-Connecting-IP (client IP) and CF-IPCountry (2-char country code) headers. Use them for geolocation analytics—no third-party lookup needed.
Performance Results
After deploying to production, here are the numbers:
| Metric | Value |
|---|---|
| P50 Latency | 12ms |
| P99 Latency | 48ms |
| Monthly Cost | $0 (free tier) |
| Uptime | 99.99% |
The consistency is remarkable. Whether your user is in Tokyo or Toronto, they get the same fast experience.
Syncing with Rails
The Rails app is the source of truth for link management. When you create or update a link, Rails enqueues a background job to sync it to Cloudflare KV.
The flow:
- Create/update link in Rails → enqueue
CloudflareSyncJob - Job wakes up and calls
LinksSyncer.sync_link(link_id, "upsert") - Syncer builds the KV payload with all link metadata
- Syncer calls
CloudflareKvClient.write(slug, payload)to push to KV - On success, update link status to
synced; on failure, retry
The syncer (simplified):
class LinksSyncer
def sync_link(link_id, operation)
link = Link.find(link_id)
# Skip if already synced (prevents duplicate API calls on retry)
return success(link) if link.sync_status == "synced" && operation == "upsert"
# Mark as syncing BEFORE calling Cloudflare
link.update!(sync_status: "syncing")
# Perform Cloudflare API call (outside transaction to avoid blocking DB)
result = perform_cloudflare_operation(link, operation)
# Update status based on result
if result[:success]
link.update!(sync_status: "synced", synced_at: Time.current)
success(link)
else
link.update!(sync_status: "failed", sync_error: result[:error])
failure(link, result[:error])
end
end
private
def perform_cloudflare_operation(link, operation)
client = CloudflareKvClient.new
if operation == "delete"
client.remove(link.slug)
else
payload = build_kv_payload(link)
client.write(link.slug, payload)
end
end
def build_kv_payload(link)
{
link_id: link.id.to_s,
destination_url: link.destination_url,
account_id: link.account_id.to_s,
slug: link.slug,
domain: link.domain,
created_at: link.created_at.iso8601,
updated_at: link.updated_at.iso8601,
utm_templates: link.account.utm_templates.map(&:to_worker_format),
link_utms: link.link_utm_params,
evolution_rules: link.evolution_rules.map(&:to_worker_format),
geo_rules: link.geo_rules,
ab_test: link.ab_test&.to_worker_format
}
end
end
The background job (thin wrapper):
class CloudflareSyncJob < ApplicationJob
queue_as "5_minutes" # 5-minute SLA
# Retry on network errors, discard on permanent errors
retry_on Net::HTTPServerError, wait: :exponentially_longer, attempts: 5
discard_on ActiveRecord::RecordNotFound
def perform(link_id, operation)
LinksSyncer.new.sync_link(link_id, operation)
end
end
The Cloudflare KV client:
class CloudflareKvClient < ApplicationClient
def initialize(account_id: CloudflareConfig.account_id,
api_token: CloudflareConfig.api_token,
namespace_id: CloudflareConfig.kv_namespace_id)
@account_id = account_id
@namespace_id = namespace_id
super(token: api_token)
end
def write(key, value)
path = "/accounts/#{@account_id}/storage/kv/namespaces/#{@namespace_id}/values/#{key}"
body = value.is_a?(String) ? value : value.to_json
put(path, body: body)
{success: true}
rescue ApplicationClient::Error => e
{success: false, error: e.message}
end
def remove(key)
path = "/accounts/#{@account_id}/storage/kv/namespaces/#{@namespace_id}/values/#{key}"
delete(path)
{success: true}
rescue ApplicationClient::Error => e
{success: false, error: e.message}
end
end
Key design patterns:
- Service layer - Business logic isolated from job queueing
- Idempotency - Skip sync if already completed (prevents duplicate API calls on retry)
- Status tracking - Mark as
syncing→syncedorfailedfor visibility - Non-blocking - Perform external API call outside database transactions
- Graceful retry - Network errors trigger job retry; permanent errors are discarded
Lessons Learned
-
KV is eventually consistent. Don’t expect immediate reads after writes. In practice, propagation takes 1-2 seconds globally.
-
Worker size matters. Keep your Worker code small. Larger bundles mean slower cold starts.
-
Test at the edge. Use
wrangler devfor local testing, but always verify on actual edge locations before launch. -
Monitor carefully. Cloudflare’s dashboard is good, but I also send custom metrics to PostHog for deeper analysis.
Advanced Features (Production)
The production worker includes several sophisticated features:
- A/B Testing - Weighted variant selection at the edge, seamlessly integrated with click analytics
- Geo-routing - Route users to different destinations based on country (no latency penalty)
- Evolution Rules - Time-based destination changes with priority-based rule evaluation
- UTM Attribution - Automatic UTM parameter injection based on referrer matching
- Rate Limiting - Per-IP rate limiting stored in KV with automatic TTL expiration
These capabilities provide powerful A/B testing, geo-localization, and conversion tracking—all without touching your origin servers.
Performance & Cost
The combination of Cloudflare Workers and KV is hard to beat:
- Cost: $0 on free tier (100k requests/day)
- Latency: P50 12ms, P99 48ms globally
- Scaling: No infrastructure management—Cloudflare handles everything
If you’re building anything redirect-heavy or need edge computing for conditional logic, Workers is an excellent choice. The global distribution is automatic, the performance is exceptional, and the free tier gives you genuine runway for new products.