Article

Building a Link Shortener with Cloudflare Workers

How I built FlowLink's redirect service to achieve sub-50ms response times globally with zero infrastructure cost.

Bill

November 23, 2025 · 12 min read

Article Info
12
Minutes
read time
Nov 23
Published
2025
3
Tags
topics
2
Related
articles

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:

  1. 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.

  2. 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.

  3. 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
💡
Pro tip: Cloudflare automatically provides 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:

  1. Create/update link in Rails → enqueue CloudflareSyncJob
  2. Job wakes up and calls LinksSyncer.sync_link(link_id, "upsert")
  3. Syncer builds the KV payload with all link metadata
  4. Syncer calls CloudflareKvClient.write(slug, payload) to push to KV
  5. 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 syncingsynced or failed for visibility
  • Non-blocking - Perform external API call outside database transactions
  • Graceful retry - Network errors trigger job retry; permanent errors are discarded

Lessons Learned

  1. KV is eventually consistent. Don’t expect immediate reads after writes. In practice, propagation takes 1-2 seconds globally.

  2. Worker size matters. Keep your Worker code small. Larger bundles mean slower cold starts.

  3. Test at the edge. Use wrangler dev for local testing, but always verify on actual edge locations before launch.

  4. 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.

This article is part of my Cloudflare series.

Build with me

Weekly insights on building SaaS with AI. Real code, real struggles, real numbers.

Subscribe Free