The first time I put a Cloudflare Worker in front of a client's WordPress site, the median time-to-first-byte for visitors in the US dropped from 480 ms to 35 ms, and the origin server in Mumbai saw 90% less traffic. The Worker was 60 lines. Edge computing gets sold as a replacement for servers; in practice it is a very fast, very cheap layer for the parts of a request that do not need your database. Here is what belongs there and what does not.

What a Worker is

A Worker is JavaScript (or Wasm) that runs in Cloudflare's 300+ locations, in a V8 isolate rather than a container. Isolates start in under a millisecond, so there is no cold-start problem, and each request runs in whichever location is closest to the user. The API is the Web platform: fetch, Request, Response, URL, Web Crypto, Web Streams. The same code runs on Bun or Node with a thin adapter, as I noted in the runtime comparison.

user (Chicago) ──▶ Cloudflare PoP (Chicago) ──▶ Worker (runs here, ~1 ms) │ ┌─────────────┼─────────────────┐ ▼ ▼ ▼ KV (replicated, Cache API origin (Mumbai) ~ms reads) (per-PoP) only when needed: 200+ ms

What runs well at the edge, and what does not

Good fitPoor fit
Routing, redirects, header manipulationAnything needing a transaction against a single-region database
Auth checks (verify a JWT, check a session in KV)Long-running CPU work (limits: 10 ms free, up to 5 min paid CPU time)
Caching and cache-key normalisationLarge in-memory state (128 MB limit per isolate)
A/B tests, feature flags, geo personalisationCode depending on Node-only modules (fs, native addons)
API aggregation and response shapingWorkloads that fan out to dozens of origin calls (each is a cross-region hop)
Static site serving (Pages), image resizingStateful WebSocket rooms (unless you use Durable Objects deliberately)

Worker 1: An HTML cache in front of WordPress

Cloudflare caches static assets by default but not HTML, because HTML is often personalised. This Worker caches HTML for anonymous visitors, bypasses for anyone with a WordPress login or WooCommerce cart cookie, and lets WordPress purge by URL.

// src/index.ts  (wrangler dev / wrangler deploy)
export interface Env { PURGE_SECRET: string; }

const BYPASS_COOKIES = /wordpress_logged_in|woocommerce_cart_hash|woocommerce_items_in_cart|wp_woocommerce_session|comment_author/;
const BYPASS_PATHS = /^\/(wp-admin|wp-login\.php|cart|checkout|my-account|wp-json\/wc)/;

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

    // Purge endpoint called by WordPress on publish (see PHP below)
    if (url.pathname === '/__purge' && req.method === 'POST') {
      if (req.headers.get('x-purge-secret') !== env.PURGE_SECRET) return new Response('nope', { status: 403 });
      const { urls } = await req.json<{ urls: string[] }>();
      await Promise.all(urls.map(u => caches.default.delete(new Request(u))));
      return Response.json({ purged: urls.length });
    }

    const cookie = req.headers.get('cookie') ?? '';
    const bypass = req.method !== 'GET' || BYPASS_PATHS.test(url.pathname) || BYPASS_COOKIES.test(cookie);
    if (bypass) return fetch(req);                                          // straight to origin, no caching

    // Normalise the cache key: drop marketing params so utm_* variants share one entry
    ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid'].forEach(p => url.searchParams.delete(p));
    const cacheKey = new Request(url.toString(), { method: 'GET' });

    const cache = caches.default;
    const hit = await cache.match(cacheKey);
    if (hit) {
      const res = new Response(hit.body, hit);
      res.headers.set('x-edge-cache', 'HIT');
      return res;
    }

    const origin = await fetch(req);
    if (origin.status !== 200 || !(origin.headers.get('content-type') ?? '').includes('text/html')) return origin;

    const res = new Response(origin.body, origin);
    res.headers.set('cache-control', 'public, s-maxage=3600, stale-while-revalidate=86400');
    res.headers.delete('set-cookie');                                       // never cache a response that sets a session
    res.headers.set('x-edge-cache', 'MISS');
    ctx.waitUntil(cache.put(cacheKey, res.clone()));                        // store after responding
    return res;
  },
};
<?php
// mu-plugins/edge-purge.php -- purge the edge cache when content changes
add_action('transition_post_status', function (string $new, string $old, WP_Post $post): void {
    if ($new !== 'publish' && $old !== 'publish') return;
    $urls = array_filter([
        get_permalink($post),
        home_url('/'),
        get_post_type_archive_link($post->post_type) ?: null,
    ]);
    wp_remote_post(home_url('/__purge'), [
        'blocking' => false,
        'headers'  => ['Content-Type' => 'application/json', 'x-purge-secret' => EDGE_PURGE_SECRET],
        'body'     => wp_json_encode(['urls' => array_values($urls)]),
    ]);
}, 10, 3);

Note that the Cache API is per-PoP, so a purge clears the location that received it; the s-maxage bounds staleness elsewhere. For global instant purge, use Cloudflare's purge-by-URL API instead of the Worker endpoint. On the client site this took origin requests from ~40,000/day to ~4,000/day and made a Mumbai-hosted site feel local in Europe and the US.

Worker 2: A/B routing without a flicker

Client-side A/B tools show variant A, then swap to B after JavaScript loads; users see the flash and Core Web Vitals get worse. At the edge, the split happens before the HTML is served:

export default {
  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname !== '/pricing') return fetch(req);

    const cookies = req.headers.get('cookie') ?? '';
    let variant = cookies.match(/ab_pricing=(a|b)/)?.[1];
    const isNew = !variant;
    if (!variant) variant = Math.random() < 0.5 ? 'a' : 'b';

    url.pathname = variant === 'b' ? '/pricing-b' : '/pricing';          // both variants exist as real pages
    const res = await fetch(new Request(url.toString(), req));
    const out = new Response(res.body, res);
    if (isNew) out.headers.append('set-cookie', `ab_pricing=${variant}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`);
    out.headers.set('x-ab-variant', variant);                            // analytics can read this
    return out;
  },
};

Worker 3: Geo personalisation with KV

KV is a globally replicated key-value store optimised for reads (single-digit ms at the edge) with eventual consistency (writes propagate within about a minute). That makes it perfect for config, feature flags, redirects and content that changes rarely, and wrong for counters or anything read-after-write.

export interface Env { GEO_CONFIG: KVNamespace; }

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const country = req.cf?.country ?? 'XX';                            // Cloudflare adds geo data to every request
    const cfg = await env.GEO_CONFIG.get<{ currency: string; banner?: string }>(`country:${country}`, { type: 'json', cacheTtl: 300 })
             ?? { currency: 'USD' };

    const res = await fetch(req);
    if (!(res.headers.get('content-type') ?? '').includes('text/html')) return res;

    // Rewrite HTML on the fly: no origin change, no client-side flicker
    return new HTMLRewriter()
      .on('[data-currency]', { element(el) { el.setInnerContent(cfg.currency); } })
      .on('#geo-banner',     { element(el) { cfg.banner ? el.setInnerContent(cfg.banner) : el.remove(); } })
      .transform(res);
  },
};

// Populate: wrangler kv key put --binding GEO_CONFIG "country:IN" '{"currency":"INR","banner":"Free shipping across India"}'

HTMLRewriter is a streaming HTML parser built into Workers; it rewrites the page as it passes through without buffering the whole document, which is why it adds almost no latency.

Choosing edge storage

ProductConsistencyBest forAvoid for
Cache APIPer-PoPResponse cachingAnything that must be global
KVEventual (~60 s)Config, flags, redirects, sessions that tolerate lagCounters, inventory, read-after-write
Durable ObjectsStrong, single instance per keyRate limiters, chat rooms, locks, collaborative stateHigh-fan-out reads (they serialise)
D1SQLite, primary + read replicasSmall relational data at the edgeMulti-GB datasets, heavy writes
R2Strong, S3 APIUploads, images, backups; zero egress feesNothing really; it is object storage
VectorizeEventualEmbeddings for edge-side semantic searchVery large or frequently updated indexes

A Durable Object rate limiter

The Redis token bucket from my system design post has a natural edge equivalent: one Durable Object per API key, holding the bucket in memory with strong consistency because all requests for that key route to the same object.

export class RateLimiter {
  private tokens = 30; private last = Date.now();
  constructor(private state: DurableObjectState) {}

  async fetch(): Promise<Response> {
    const now = Date.now();
    this.tokens = Math.min(30, this.tokens + ((now - this.last) / 1000) * 10);   // 10/s, burst 30
    this.last = now;
    if (this.tokens < 1) return new Response('limited', { status: 429 });
    this.tokens -= 1;
    return new Response('ok');
  }
}

// In the Worker:
const id = env.LIMITER.idFromName(apiKey);
const allowed = (await env.LIMITER.get(id).fetch('https://limiter/')).ok;
if (!allowed) return new Response('Too many requests', { status: 429 });

Development and deployment

npm create cloudflare@latest edge-cache -- --type hello-world --ts
cd edge-cache
npx wrangler dev                       # local emulation with Miniflare, including KV and DO
npx wrangler deploy                    # to *.workers.dev
npx wrangler deploy --route "client-site.com/*"    # in front of the real site
# wrangler.toml
name = "edge-cache"
main = "src/index.ts"
compatibility_date = "2026-08-01"

routes = [{ pattern = "client-site.com/*", zone_name = "client-site.com" }]

[[kv_namespaces]]
binding = "GEO_CONFIG"
id = "…"

[vars]
# non-secret config; secrets via: wrangler secret put PURGE_SECRET

Deploy it from the same GitHub Actions pipeline with cloudflare/wrangler-action and an API token scoped to Workers only.

The rule of thumb

If the logic needs the database, keep it on the origin. If it needs only the request, a fast KV lookup or a cached response, move it to the edge. That single question sorts almost every feature correctly, and the ones it sorts to the edge get faster, cheaper and more resilient at the same time.

Workers changed how I think about WordPress hosting in particular: a modest VPS plus an edge cache and purge hook outperforms expensive managed hosting for most content sites. The Nginx side of that stack is in Nginx Performance Tuning for PHP, and the metrics you are ultimately chasing are in Core Web Vitals in 2026.