After two decades of designing systems on Azure, I thought I understood distributed computing. I understood regions, availability zones, VNets, and the art of routing traffic intelligently across a global backbone. I understood serverless — Azure Functions, consumption plans, cold starts, and the eternal debate over premium tiers.

Then I started building seriously with Cloudflare Workers, and I had to unlearn a surprising amount of what I thought I knew.

This isn’t a post about Workers being “better” than Azure Functions. It’s about something more fundamental: the edge forces you to think about architecture differently from the ground up. The constraints are different, the primitives are different, and if you try to design edge applications the way you’d design a traditional cloud application, you’ll fight the platform at every turn.

What “The Edge” Actually Means

We’ve abused the word “edge” for years. CDN caches are “edge.” Regional deployments are “edge.” Any compute that isn’t in your datacenter has been called edge at some point.

Cloudflare Workers is something more specific and more radical: code that runs in 300+ locations simultaneously, with no concept of a home region. When a request hits Workers, it executes in the Cloudflare PoP (Point of Presence) closest to the user — not a region you’ve configured, not a primary with a failover, but literally the nearest node on the planet.

This sounds like a deployment detail. It isn’t. It’s an architectural primitive.

The Three Mental Shifts

1. Abandon the “Closest Region” Pattern

In Azure architecture, global distribution usually means: pick a primary region, replicate data to secondary regions, and route users to the closest one via Traffic Manager or Front Door. You’re still thinking in terms of regions you own.

Workers doesn’t have regions. Your Worker is everywhere the moment you deploy it. This forces a different question: instead of asking “where should this run?” you start asking “what does this Worker need access to that has location constraints?”

That’s almost always your data layer. Durable Objects give you strongly-consistent coordination at a specific location while your compute runs anywhere. KV gives you eventually-consistent reads globally. The architecture question becomes about data gravity, not compute placement.

2. The Request is the Unit of Work

Azure Functions can be long-running. With Durable Functions, you can orchestrate workflows spanning minutes, hours, or days. Workers has a different contract: a CPU time limit, no filesystem, no arbitrary TCP. The V8 isolate model means Workers start in microseconds — but the environment is deliberately sandboxed.

This constraint is clarifying. It pushes you toward composable, single-responsibility handlers that do one thing well and hand off to queues or downstream services for anything heavier:

export interface Env {
  CONFIG: KVNamespace;
  COMPILE_QUEUE: Queue;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (request.method !== 'POST' || url.pathname !== '/api/compile') {
      return new Response('Not found', { status: 404 });
    }
    const body = await request.json<{ listId: string; userId: string }>();
    // Fast path: deduplicate with a short-lived KV lock
    const lockKey = `lock:compile:${body.listId}`;
    if (await env.CONFIG.get(lockKey)) {
      return Response.json({ status: 'already_queued', listId: body.listId });
    }
    await env.COMPILE_QUEUE.send({ ...body, requestedAt: Date.now() });
    await env.CONFIG.put(lockKey, '1', { expirationTtl: 60 });
    return Response.json({ status: 'queued', listId: body.listId }, { status: 202 });
  },
};

No business logic — just intake, deduplication, and handoff. The Worker does exactly one thing.

3. Think in Pipelines, Not Services

Service Bindings let Workers call other Workers directly, without going over the public internet. No HTTP round-trip, no latency penalty. Microservice-style composition at the edge without microservice-style networking overhead:

export interface Env {
  AUTH_WORKER: Fetcher;      // token validation
  RATELIMIT_WORKER: Fetcher; // per-user rate limiting
  LISTS_WORKER: Fetcher;     // blocklist CRUD
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Each fetch() here is a zero-latency internal call, not an HTTP round-trip
    const auth = await env.AUTH_WORKER.fetch(
      new Request('https://internal/validate', {
        method: 'POST',
        headers: { Authorization: request.headers.get('Authorization') ?? '' },
      })
    );
    if (!auth.ok) return new Response('Unauthorized', { status: 401 });
    const { userId } = await auth.json<{ userId: string }>();

    const rl = await env.RATELIMIT_WORKER.fetch(
      new Request(`https://internal/check/${userId}`)
    );
    if (rl.status === 429) return rl;

    return env.LISTS_WORKER.fetch(
      new Request(request.url, {
        method: request.method,
        headers: { ...Object.fromEntries(request.headers), 'X-User-Id': userId },
        body: request.body,
      })
    );
  },
};

Where It Changes Your Data Architecture

The biggest shift for me wasn’t the compute model — it was rethinking data access patterns. When your compute is running in Canberra because that’s where the user is, and your database is in East US, that’s 200ms of latency on every query. That’s not a caching problem — that’s a fundamental mismatch between compute placement and data placement.

Read from the Edge, Write to Origin

KV is excellent for data that’s read frequently and written infrequently — configuration, feature flags, compiled artifacts. Reads are served from the local PoP (typically sub-millisecond). On a miss, fetch from origin, write back with a TTL, and return:

async function getBlocklistConfig(
  kv: KVNamespace,
  listId: string
): Promise<BlocklistConfig | null> {
  const cached = await kv.get<BlocklistConfig>(`config:list:${listId}`, 'json');
  if (cached) return cached; // served from local PoP

  const origin = await fetch(`https://api.internal/lists/${listId}/config`);
  if (!origin.ok) return null;
  const config = await origin.json<BlocklistConfig>();

  await kv.put(`config:list:${listId}`, JSON.stringify(config), {
    expirationTtl: 300, // 5-minute TTL keeps stale data bounded
  });
  return config;
}

Use Durable Objects for Coordination, Not Storage

Durable Objects are strongly consistent and co-located — excellent for rate limiters, presence tracking, and distributed locks. The DO lives in one location; your Worker calling it can be anywhere. Here’s a sliding-window rate limiter:

export class RateLimiter implements DurableObject {
  constructor(private state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    const now = Date.now();
    const windowMs = 60_000;
    const maxRequests = 100;

    const timestamps: number[] =
      (await this.state.storage.get<number[]>('timestamps')) ?? [];
    const recent = timestamps.filter(t => t > now - windowMs);

    if (recent.length >= maxRequests) {
      const retryAfter = Math.ceil((recent[0] - (now - windowMs)) / 1000);
      return new Response('Rate limit exceeded', {
        status: 429,
        headers: { 'Retry-After': String(retryAfter), 'X-RateLimit-Remaining': '0' },
      });
    }

    recent.push(now);
    await this.state.storage.put('timestamps', recent);
    return new Response('OK', {
      headers: { 'X-RateLimit-Remaining': String(maxRequests - recent.length) },
    });
  }
}

Embrace Queues for Write-Heavy Work

Queue consumers get automatic retries with backoff and clean separation between intake (fast) and work (resilient). Here’s the consumer side of an adblock compiler pipeline:

export default {
  async queue(batch: MessageBatch<CompileMessage>, env: Env): Promise<void> {
    for (const message of batch.messages) {
      const { listId, userId } = message.body;
      try {
        const raw = await env.CONFIG.get(`raw:list:${listId}`);
        if (!raw) { message.ack(); continue; }

        const compiled = compileBlocklist(raw);
        await env.COMPILED_LISTS.put(`lists/${listId}/compiled.txt`, compiled, {
          httpMetadata: { contentType: 'text/plain' },
          customMetadata: { compiledAt: Date.now().toString(), userId },
        });

        // Invalidate KV cache so the next read picks up the new version
        await env.CONFIG.delete(`config:list:${listId}`);

        await env.NOTIFY_WORKER.fetch(
          new Request(`https://internal/notify/${userId}`, {
            method: 'POST',
            body: JSON.stringify({ listId, event: 'compile_complete' }),
          })
        );
        message.ack();
      } catch (err) {
        console.error(`Failed to compile ${listId}:`, err);
        message.retry(); // Cloudflare redelivers with exponential backoff
      }
    }
  },
};

function compileBlocklist(raw: string): string {
  const rules = [...new Set(
    raw.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
  )].sort();
  return `# Compiled blocklist — ${rules.length} rules\n${rules.join('\n')}`;
}

What This Means If You’re Coming From Azure

If you’re an Azure architect exploring Workers, here’s my honest assessment: the platform will feel limiting at first, and then it will feel clarifying.

The things you’ll miss: long-running orchestrations, rich SDK ecosystems, familiar IAM patterns, and the comfort of knowing exactly where your code is running. The things you’ll gain: sub-millisecond cold starts, global distribution with zero configuration, a pricing model that rewards efficient code, and an architectural discipline that emerges from working within constraints.

Workers doesn’t replace Azure in my stack. My APIs and background services still live in Azure where they need the full power of the platform. But Workers sits in front of all of it — handling auth, routing, rate limiting, response shaping, and any logic that benefits from running as close to the user as possible.

The edge isn’t a deployment target. It’s a layer of your architecture. Once you start treating it that way, the design possibilities open up considerably.


Have questions about edge-first architecture or how Workers fits into a broader Azure strategy? Drop a comment below or find me on GitHub @jaypatrick.


Discover more from The [K]nightly Build

Subscribe to get the latest posts sent to your email.

, , ,

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.