DEV Community

Wilson Xu
Wilson Xu

Posted on

Edge Computing with Cloudflare Workers: A Complete Guide for 2026

Edge Computing with Cloudflare Workers: A Complete Guide for 2026

Modern web applications demand sub-50ms response times worldwide. Traditional server architectures, even with CDNs caching static assets, still force API requests to travel thousands of miles to a single origin server. Edge computing flips this model: your code runs in data centers closest to your users, across 300+ cities globally.

Cloudflare Workers is the most mature edge runtime available today. It runs JavaScript, TypeScript, and WebAssembly at the network edge with zero cold starts, built-in KV storage, durable objects for stateful coordination, and a developer experience that feels remarkably close to writing standard Node.js code.

This guide walks you through building production-grade edge applications with Cloudflare Workers. You will start with fundamentals, progress through real-world patterns like authentication middleware, database access at the edge, and caching strategies, and finish with deployment and observability. Every code example is tested and production-ready.

Prerequisites

You need Node.js 18+ installed and a free Cloudflare account. Some examples use Cloudflare's paid features (Durable Objects, D1), but you can follow along with the free tier for most sections.

Install the Wrangler CLI, Cloudflare's official development tool:

npm install -g wrangler
wrangler login
Enter fullscreen mode Exit fullscreen mode

Verify the installation:

wrangler --version
# wrangler 3.x.x
Enter fullscreen mode Exit fullscreen mode

Understanding the Edge Runtime

Before writing code, it helps to understand what makes Workers different from Node.js or Deno.

Workers run on the V8 isolate model, not containers or virtual machines. Each incoming request gets its own lightweight isolate that spins up in under 5 milliseconds. This eliminates cold starts entirely -- a problem that plagues AWS Lambda and similar platforms.

The runtime is intentionally constrained. There is no filesystem access, no long-running processes, and CPU time is limited to 10ms on the free tier (50ms on paid). These constraints exist because your code shares hardware with thousands of other Workers. In exchange, you get:

  • Global distribution: your code runs in 300+ data centers automatically
  • Zero cold starts: isolates initialize in microseconds
  • Massive scale: handles millions of requests per second with no provisioning
  • Low cost: the free tier includes 100,000 requests per day

The execution model also differs. Workers use the Service Worker API pattern. Instead of Express-style route handlers, you respond to fetch events:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return new Response("Hello from the edge!");
  },
};
Enter fullscreen mode Exit fullscreen mode

This fetch handler is the entry point for every request. The env object provides access to bindings like KV stores, databases, and secrets. The ctx object lets you perform work after the response is sent.

Project Setup: Building an Edge API

Let us build a practical project: a URL shortener API that runs entirely at the edge. This covers routing, KV storage, authentication, error handling, and caching.

Scaffold the project:

wrangler init url-shortener --type javascript
cd url-shortener
Enter fullscreen mode Exit fullscreen mode

Replace the generated wrangler.toml with a proper configuration:

name = "url-shortener"
main = "src/index.ts"
compatibility_date = "2026-03-01"

[vars]
ENVIRONMENT = "production"

[[kv_namespaces]]
binding = "URLS"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
Enter fullscreen mode Exit fullscreen mode

Create the KV namespace:

wrangler kv:namespace create "URLS"
wrangler kv:namespace create "URLS" --preview
Enter fullscreen mode Exit fullscreen mode

Copy the generated IDs into your wrangler.toml.

Routing Without a Framework

Workers do not ship with a built-in router, but you do not need a framework for most use cases. A clean pattern using URL and switch handles routing efficiently:

// src/index.ts

interface Env {
  URLS: KVNamespace;
  API_KEY: string;
  ENVIRONMENT: string;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    try {
      // Health check
      if (path === "/health") {
        return json({ status: "ok", region: request.cf?.colo });
      }

      // API routes
      if (path === "/api/shorten" && request.method === "POST") {
        return handleShorten(request, env);
      }

      if (path === "/api/stats" && request.method === "GET") {
        return handleStats(request, env);
      }

      // Redirect route: /:code
      const code = path.slice(1);
      if (code && !code.includes("/")) {
        return handleRedirect(code, env, ctx);
      }

      return json({ error: "Not found" }, 404);
    } catch (err) {
      console.error("Unhandled error:", err);
      return json({ error: "Internal server error" }, 500);
    }
  },
};

function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This pattern scales well to 20-30 routes. For larger applications, consider the itty-router library, which adds under 1KB to your bundle.

Working with KV Storage

Workers KV is a globally distributed key-value store optimized for read-heavy workloads. Writes propagate globally within 60 seconds, and reads are served from the nearest data center.

Implement the URL shortening logic:

interface ShortenRequest {
  url: string;
  customCode?: string;
  expiresIn?: number; // seconds
}

interface UrlEntry {
  originalUrl: string;
  createdAt: string;
  clicks: number;
  lastClickedAt: string | null;
}

async function handleShorten(request: Request, env: Env): Promise<Response> {
  // Authenticate
  const apiKey = request.headers.get("X-API-Key");
  if (apiKey !== env.API_KEY) {
    return json({ error: "Unauthorized" }, 401);
  }

  const body = await request.json<ShortenRequest>();

  // Validate URL
  try {
    new URL(body.url);
  } catch {
    return json({ error: "Invalid URL" }, 400);
  }

  // Generate or use custom code
  const code = body.customCode || generateCode(6);

  // Check for collision
  const existing = await env.URLS.get(code);
  if (existing) {
    return json({ error: "Code already in use" }, 409);
  }

  const entry: UrlEntry = {
    originalUrl: body.url,
    createdAt: new Date().toISOString(),
    clicks: 0,
    lastClickedAt: null,
  };

  // Store with optional TTL
  const options: KVNamespacePutOptions = {};
  if (body.expiresIn) {
    options.expirationTtl = body.expiresIn;
  }

  await env.URLS.put(code, JSON.stringify(entry), options);

  return json({
    shortUrl: `https://url-shortener.your-domain.workers.dev/${code}`,
    code,
    expiresIn: body.expiresIn || null,
  }, 201);
}

function generateCode(length: number): string {
  const chars = "abcdefghijkmnpqrstuvwxyz23456789";
  const bytes = new Uint8Array(length);
  crypto.getRandomValues(bytes);
  return Array.from(bytes, (b) => chars[b % chars.length]).join("");
}
Enter fullscreen mode Exit fullscreen mode

The redirect handler reads from KV, updates click stats, and returns a 301:

async function handleRedirect(
  code: string,
  env: Env,
  ctx: ExecutionContext
): Promise<Response> {
  const data = await env.URLS.get(code);

  if (!data) {
    return json({ error: "Short URL not found" }, 404);
  }

  const entry: UrlEntry = JSON.parse(data);

  // Update stats asynchronously after response is sent
  ctx.waitUntil(updateClickStats(code, entry, env));

  return new Response(null, {
    status: 301,
    headers: {
      Location: entry.originalUrl,
      "Cache-Control": "public, max-age=300",
    },
  });
}

async function updateClickStats(
  code: string,
  entry: UrlEntry,
  env: Env
): Promise<void> {
  entry.clicks += 1;
  entry.lastClickedAt = new Date().toISOString();
  await env.URLS.put(code, JSON.stringify(entry));
}
Enter fullscreen mode Exit fullscreen mode

Notice the ctx.waitUntil() pattern. This lets you perform background work after the response is already sent to the user. The redirect response goes out immediately, and the click count update happens asynchronously.

Adding a D1 Database for Structured Data

For workloads that need SQL queries, joins, or transactions, Cloudflare D1 provides a SQLite database at the edge. D1 is ideal for analytics, user data, and anything that outgrows simple key-value access.

Create a D1 database:

wrangler d1 create url-analytics
Enter fullscreen mode Exit fullscreen mode

Add the binding to wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "url-analytics"
database_id = "your-database-id"
Enter fullscreen mode Exit fullscreen mode

Create the schema with a migration:

wrangler d1 migrations create url-analytics init
Enter fullscreen mode Exit fullscreen mode

Write the migration SQL:

-- migrations/0001_init.sql

CREATE TABLE IF NOT EXISTS clicks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  code TEXT NOT NULL,
  clicked_at TEXT NOT NULL DEFAULT (datetime('now')),
  country TEXT,
  device TEXT,
  referer TEXT
);

CREATE INDEX idx_clicks_code ON clicks(code);
CREATE INDEX idx_clicks_date ON clicks(clicked_at);

CREATE TABLE IF NOT EXISTS daily_stats (
  code TEXT NOT NULL,
  date TEXT NOT NULL,
  click_count INTEGER DEFAULT 0,
  unique_countries INTEGER DEFAULT 0,
  PRIMARY KEY (code, date)
);
Enter fullscreen mode Exit fullscreen mode

Apply the migration:

wrangler d1 migrations apply url-analytics
Enter fullscreen mode Exit fullscreen mode

Now update the click tracking to write structured analytics:

interface Env {
  URLS: KVNamespace;
  DB: D1Database;
  API_KEY: string;
}

async function recordClick(
  code: string,
  request: Request,
  env: Env
): Promise<void> {
  const cf = request.cf;
  const country = (cf?.country as string) || "unknown";
  const device = parseDevice(request.headers.get("User-Agent") || "");
  const referer = request.headers.get("Referer") || "direct";

  // Insert click record
  await env.DB.prepare(
    `INSERT INTO clicks (code, country, device, referer) VALUES (?, ?, ?, ?)`
  )
    .bind(code, country, device, referer)
    .run();

  // Upsert daily stats
  const today = new Date().toISOString().split("T")[0];
  await env.DB.prepare(
    `INSERT INTO daily_stats (code, date, click_count, unique_countries)
     VALUES (?, ?, 1, 1)
     ON CONFLICT(code, date) DO UPDATE SET
       click_count = click_count + 1,
       unique_countries = (
         SELECT COUNT(DISTINCT country) FROM clicks
         WHERE code = ? AND clicked_at >= ?
       )`
  )
    .bind(code, today, code, today + "T00:00:00")
    .run();
}

function parseDevice(ua: string): string {
  if (/mobile/i.test(ua)) return "mobile";
  if (/tablet/i.test(ua)) return "tablet";
  return "desktop";
}
Enter fullscreen mode Exit fullscreen mode

Build the stats endpoint with D1 queries:

async function handleStats(request: Request, env: Env): Promise<Response> {
  const apiKey = request.headers.get("X-API-Key");
  if (apiKey !== env.API_KEY) {
    return json({ error: "Unauthorized" }, 401);
  }

  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const days = parseInt(url.searchParams.get("days") || "7", 10);

  if (!code) {
    return json({ error: "Missing 'code' parameter" }, 400);
  }

  const since = new Date();
  since.setDate(since.getDate() - days);
  const sinceStr = since.toISOString();

  // Run queries in parallel using D1 batch
  const [clicksByDay, clicksByCountry, totalClicks] = await env.DB.batch([
    env.DB.prepare(
      `SELECT date, click_count FROM daily_stats
       WHERE code = ? AND date >= ?
       ORDER BY date ASC`
    ).bind(code, sinceStr.split("T")[0]),

    env.DB.prepare(
      `SELECT country, COUNT(*) as count FROM clicks
       WHERE code = ? AND clicked_at >= ?
       GROUP BY country ORDER BY count DESC LIMIT 10`
    ).bind(code, sinceStr),

    env.DB.prepare(
      `SELECT COUNT(*) as total FROM clicks WHERE code = ?`
    ).bind(code),
  ]);

  return json({
    code,
    period: `${days} days`,
    totalClicks: totalClicks.results[0]?.total || 0,
    clicksByDay: clicksByDay.results,
    topCountries: clicksByCountry.results,
  });
}
Enter fullscreen mode Exit fullscreen mode

The DB.batch() method sends multiple queries in a single round trip, reducing latency significantly compared to sequential queries.

Middleware Pattern: Authentication and Rate Limiting

Real applications need middleware. Workers do not have an Express-style middleware chain, but you can compose handlers cleanly:

type Handler = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
type Middleware = (handler: Handler) => Handler;

function withAuth(handler: Handler): Handler {
  return async (request, env, ctx) => {
    const apiKey = request.headers.get("X-API-Key");
    if (!apiKey || apiKey !== env.API_KEY) {
      return json({ error: "Invalid API key" }, 401);
    }
    return handler(request, env, ctx);
  };
}

function withRateLimit(limit: number, windowSecs: number): Middleware {
  return (handler) => async (request, env, ctx) => {
    const ip = request.headers.get("CF-Connecting-IP") || "unknown";
    const key = `ratelimit:${ip}`;

    const current = parseInt(await env.URLS.get(key) || "0", 10);

    if (current >= limit) {
      return json(
        { error: "Rate limit exceeded", retryAfter: windowSecs },
        429
      );
    }

    // Increment counter with TTL
    await env.URLS.put(key, String(current + 1), {
      expirationTtl: windowSecs,
    });

    return handler(request, env, ctx);
  };
}

function withCors(handler: Handler): Handler {
  return async (request, env, ctx) => {
    // Handle preflight
    if (request.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
          "Access-Control-Max-Age": "86400",
        },
      });
    }

    const response = await handler(request, env, ctx);

    // Clone and add CORS headers
    const newHeaders = new Headers(response.headers);
    newHeaders.set("Access-Control-Allow-Origin", "*");

    return new Response(response.body, {
      status: response.status,
      headers: newHeaders,
    });
  };
}

// Compose middleware
const protectedShorten = withCors(
  withAuth(
    withRateLimit(100, 3600)(handleShorten)
  )
);
Enter fullscreen mode Exit fullscreen mode

This composition pattern keeps each concern isolated and testable. You can mix and match middleware per route without a framework.

Caching Strategies at the Edge

Cloudflare Workers sit in front of Cloudflare's cache. You can use the Cache API to store computed responses and avoid redundant KV or D1 reads:

async function handleRedirectWithCache(
  code: string,
  env: Env,
  ctx: ExecutionContext,
  request: Request
): Promise<Response> {
  const cacheKey = new Request(request.url, { method: "GET" });
  const cache = caches.default;

  // Check cache first
  let response = await cache.match(cacheKey);
  if (response) {
    return response;
  }

  // Cache miss: read from KV
  const data = await env.URLS.get(code);
  if (!data) {
    return json({ error: "Not found" }, 404);
  }

  const entry: UrlEntry = JSON.parse(data);

  response = new Response(null, {
    status: 301,
    headers: {
      Location: entry.originalUrl,
      "Cache-Control": "public, max-age=300, s-maxage=600",
    },
  });

  // Store in cache asynchronously
  ctx.waitUntil(cache.put(cacheKey, response.clone()));

  // Record click asynchronously
  ctx.waitUntil(recordClick(code, request, env));

  return response;
}
Enter fullscreen mode Exit fullscreen mode

For dynamic content that changes frequently, use a stale-while-revalidate pattern:

async function staleWhileRevalidate(
  cacheKey: Request,
  fetchFresh: () => Promise<Response>,
  ctx: ExecutionContext,
  maxAge = 60,
  staleAge = 300
): Promise<Response> {
  const cache = caches.default;
  const cached = await cache.match(cacheKey);

  if (cached) {
    const age = parseInt(cached.headers.get("Age") || "0", 10);

    if (age < maxAge) {
      // Fresh: return cached
      return cached;
    }

    if (age < staleAge) {
      // Stale but acceptable: return cached, revalidate in background
      ctx.waitUntil(
        fetchFresh().then((fresh) => cache.put(cacheKey, fresh.clone()))
      );
      return cached;
    }
  }

  // Expired or missing: fetch fresh
  const fresh = await fetchFresh();
  ctx.waitUntil(cache.put(cacheKey, fresh.clone()));
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

Testing Workers Locally

Wrangler includes Miniflare, a local simulator that replicates the Workers runtime faithfully. Run your Worker locally:

wrangler dev
Enter fullscreen mode Exit fullscreen mode

This starts a local server on http://localhost:8787 with hot reloading. KV, D1, and Durable Objects all work locally with in-memory or file-based storage.

Write integration tests using the unstable_dev API:

// src/index.test.ts
import { unstable_dev } from "wrangler";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

describe("URL Shortener", () => {
  let worker: Awaited<ReturnType<typeof unstable_dev>>;

  beforeAll(async () => {
    worker = await unstable_dev("src/index.ts", {
      experimental: { disableExperimentalWarning: true },
    });
  });

  afterAll(async () => {
    await worker.stop();
  });

  it("returns health check with region info", async () => {
    const resp = await worker.fetch("/health");
    const data = await resp.json();
    expect(resp.status).toBe(200);
    expect(data.status).toBe("ok");
  });

  it("rejects unauthenticated shorten requests", async () => {
    const resp = await worker.fetch("/api/shorten", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url: "https://example.com" }),
    });
    expect(resp.status).toBe(401);
  });

  it("creates and resolves a short URL", async () => {
    // Create
    const createResp = await worker.fetch("/api/shorten", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": "test-key",
      },
      body: JSON.stringify({
        url: "https://example.com/long-page",
        customCode: "test123",
      }),
    });
    expect(createResp.status).toBe(201);

    // Resolve
    const redirectResp = await worker.fetch("/test123", { redirect: "manual" });
    expect(redirectResp.status).toBe(301);
    expect(redirectResp.headers.get("Location")).toBe(
      "https://example.com/long-page"
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Run tests with Vitest:

npx vitest run
Enter fullscreen mode Exit fullscreen mode

Deploying to Production

Deploy with a single command:

wrangler deploy
Enter fullscreen mode Exit fullscreen mode

This uploads your Worker to all 300+ Cloudflare edge locations simultaneously. The deployment takes about 30 seconds and has zero downtime -- new requests hit the new version immediately while in-flight requests complete on the old version.

For production deployments, use environments and secrets:

# Set secrets (never put these in wrangler.toml)
wrangler secret put API_KEY

# Deploy to staging
wrangler deploy --env staging

# Deploy to production
wrangler deploy --env production
Enter fullscreen mode Exit fullscreen mode

Configure environments in wrangler.toml:

[env.staging]
name = "url-shortener-staging"
vars = { ENVIRONMENT = "staging" }

[env.production]
name = "url-shortener-production"
vars = { ENVIRONMENT = "production" }
routes = [
  { pattern = "short.yourdomain.com/*", zone_name = "yourdomain.com" }
]
Enter fullscreen mode Exit fullscreen mode

Observability and Debugging

Workers provides built-in logging through wrangler tail:

wrangler tail --format json
Enter fullscreen mode Exit fullscreen mode

For structured observability, add request tracing:

async function withTracing(
  request: Request,
  env: Env,
  handler: () => Promise<Response>
): Promise<Response> {
  const start = Date.now();
  const requestId = crypto.randomUUID();

  console.log(
    JSON.stringify({
      level: "info",
      requestId,
      method: request.method,
      path: new URL(request.url).pathname,
      country: request.cf?.country,
      colo: request.cf?.colo,
    })
  );

  try {
    const response = await handler();
    const duration = Date.now() - start;

    console.log(
      JSON.stringify({
        level: "info",
        requestId,
        status: response.status,
        durationMs: duration,
      })
    );

    // Add trace headers to response
    const headers = new Headers(response.headers);
    headers.set("X-Request-Id", requestId);
    headers.set("X-Response-Time", `${duration}ms`);
    headers.set("X-Edge-Location", (request.cf?.colo as string) || "unknown");

    return new Response(response.body, {
      status: response.status,
      headers,
    });
  } catch (err) {
    console.error(
      JSON.stringify({
        level: "error",
        requestId,
        error: err instanceof Error ? err.message : String(err),
        stack: err instanceof Error ? err.stack : undefined,
      })
    );
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

For production monitoring, send logs to an external service using ctx.waitUntil():

async function sendToLogDrain(
  logs: Record<string, unknown>[],
  ctx: ExecutionContext
): Promise<void> {
  ctx.waitUntil(
    fetch("https://your-log-service.com/ingest", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer your-token",
      },
      body: JSON.stringify({ logs }),
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Edge computing requires a different performance mindset than traditional servers.

Minimize KV reads per request. Each KV read adds 10-30ms of latency. Batch reads when possible, and use the Cache API to avoid repeated lookups.

Use ctx.waitUntil() for non-critical work. Analytics writes, log shipping, and cache warming should never block the response.

Watch your bundle size. Workers have a 10MB limit (compressed) on the paid plan, 1MB on free. Use tree-shaking and avoid large dependencies. The wrangler bundler handles this automatically when you use ES module syntax.

Avoid await chains. When you need data from multiple sources, fetch in parallel:

// Slow: sequential
const user = await env.USERS.get(userId);
const prefs = await env.PREFS.get(userId);

// Fast: parallel
const [user, prefs] = await Promise.all([
  env.USERS.get(userId),
  env.PREFS.get(userId),
]);
Enter fullscreen mode Exit fullscreen mode

Leverage the cf object. Every request includes geolocation data for free -- country, city, timezone, ASN, and more. Use this for personalization without calling external APIs:

const country = request.cf?.country as string;
const timezone = request.cf?.timezone as string;

if (country === "DE") {
  // GDPR-specific logic
}
Enter fullscreen mode Exit fullscreen mode

When to Use Workers vs. Traditional Servers

Workers excel at:

  • API gateways and routing: add auth, rate limiting, and transformation at the edge
  • Personalization: serve localized content based on geolocation
  • A/B testing: split traffic without origin server involvement
  • URL shorteners and redirects: sub-10ms responses globally
  • Webhooks and event processing: handle spiky traffic without provisioning
  • API aggregation: combine multiple backend APIs into a single edge response

Workers are not ideal for:

  • Long-running computations: CPU time limits (50ms on paid) make heavy processing impractical
  • Large data processing: no filesystem, limited memory
  • WebSocket-heavy applications: better handled by Durable Objects or dedicated WebSocket servers
  • Applications that need full Node.js compatibility: some Node.js APIs are not available

Conclusion

Cloudflare Workers provides a fundamentally different deployment model. Instead of choosing a region and hoping your CDN handles the rest, your application logic runs in 300+ locations by default. The V8 isolate model eliminates cold starts, the pricing model scales linearly with usage, and the developer experience with Wrangler makes local development and deployment straightforward.

The URL shortener we built demonstrates core patterns you will use in any edge application: KV for fast key-value access, D1 for structured queries, the Cache API for response caching, middleware composition for cross-cutting concerns, and ctx.waitUntil() for background processing.

Start with a single Worker that handles your most latency-sensitive endpoint. Measure the latency improvement compared to your origin server. Once you see the difference between 200ms and 15ms response times for users on different continents, you will find more and more logic that belongs at the edge.

The edge is not replacing traditional servers. It is becoming the first layer your users interact with, handling everything that does not require a persistent connection to your primary database. Build that layer well, and your application feels instant everywhere in the world.

Top comments (0)