Cloudflare Workers Routing
Cloudflare Workers Routing lets you intercept, inspect, and direct HTTP traffic at the network edge before it ever reaches an origin, using V8 isolates that boot in under a millisecond and run within a few miles of the user.
As a core building block of modern Edge Routing & Serverless Function Architecture, Workers routing replaces brittle Page Rules and centralized load balancers with programmable JavaScript that decides — per request — which origin answers, what the path becomes, and which headers travel onward. Unlike framework-tied approaches such as Vercel Edge Middleware, the Workers model is decoupled from any application framework, so the same routing layer fronts a Rails monolith, a static bucket, and a Go API simultaneously. If you are weighing platforms, the trade-offs are covered in depth in Cloudflare Workers vs AWS Lambda@Edge for Request Routing, and the hands-on deployment lifecycle is detailed in Deploying Cloudflare Workers for Dynamic Request Routing.
Key Routing Principles:
- Route evaluation follows pattern specificity, not declaration order: the most specific matching pattern wins, with exact paths beating prefixes and prefixes beating wildcards.
- Declarative routes in
wrangler.toml(or the API) replace legacy Page Rules and give you version control, environment overrides, and reproducible deploys. - A matched route runs the Worker before the cache by default, but the Worker itself controls whether the cache is read, written, or bypassed via
fetch()options and the Cache API. - Observability depends on
cf-raycorrelation,wrangler taillog streaming, and the Workers Analytics Engine — there is no origin access log for traffic the Worker terminates.
Route Matching & Priority Architecture
A Worker only runs when an incoming request matches one of its route patterns. A pattern looks like example.com/api/* and is attached to a specific zone. Once attached, the Worker intercepts every request whose URL matches the pattern before that request reaches the cache or the origin.
The single most misunderstood detail is which route wins when several patterns could match. Cloudflare does not evaluate routes in declaration order; it ranks them by specificity and picks the most specific match. A literal path segment outranks a wildcard at the same position, and a longer pattern outranks a shorter one. This is why a catch-all example.com/* and a targeted example.com/api/* coexist safely: the API request always lands on the API Worker.
| Pattern Type | Example | Specificity | Execution Behavior |
|---|---|---|---|
| Exact path | example.com/api/v1/status |
Highest | Runs only for that precise URL |
| Prefix wildcard | example.com/api/* |
High | Matches every path under /api/ |
| Subdomain wildcard | *.example.com/* |
Low | Matches all hosts and paths in the zone |
| Zone catch-all | example.com/* |
Lowest | Fallback when nothing more specific matches |
Patterns match the host and path but never the query string or scheme — https:// and http:// are treated identically, so do not rely on the route layer to enforce TLS; do that with an Always Use HTTPS rule or a redirect inside the Worker. Subdomain routing requires that the hostname already resolves through Cloudflare (an orange-clouded DNS record), otherwise the route silently never fires. For multi-tenant SaaS, prefer path prefixes (app.example.com/t/{tenant}/*) over a wildcard subdomain when tenants share one Worker, because wildcard-subdomain routes are the lowest-specificity tier and are easy to shadow accidentally. Once the request is inside the isolate, the heavy lifting of header and path manipulation belongs to the Request/Response Transformation layer, and authentication or quota logic belongs to the API Gateway at the Edge patterns.
Provider Implementations
The routing concept — intercept at the edge, inspect, rewrite, forward — is portable, but every platform exposes it differently. The snippets below implement the same behavior (route /api/v2/* to a versioned origin, inject a trace header) on each major edge runtime.
Cloudflare Workers
On Cloudflare the route binding lives in config and the logic lives in the fetch handler. The Worker clones the incoming request so the immutable original is left intact, then forwards.
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith('/api/v2')) {
const target = 'https://origin-v2.example.com' + url.pathname + url.search;
const proxied = new Request(target, request);
proxied.headers.set('X-Edge-Routed', 'cf-workers');
return fetch(proxied);
}
return new Response('Not Found', { status: 404 });
}
};
# wrangler.toml
name = "api-router"
main = "src/index.ts"
compatibility_date = "2024-09-23"
[[routes]]
pattern = "api.example.com/v2/*"
zone_name = "example.com"
AWS Lambda@Edge
Lambda@Edge ties routing to CloudFront behaviors and event types (viewer-request, origin-request). You mutate the request object and return it, rather than calling fetch yourself; CloudFront performs the origin hop.
'use strict';
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
if (request.uri.startsWith('/api/v2')) {
request.origin = {
custom: {
domainName: 'origin-v2.example.com',
port: 443, protocol: 'https',
sslProtocols: ['TLSv1.2'],
path: '', readTimeout: 30, keepaliveTimeout: 5,
customHeaders: {}
}
};
request.headers['x-edge-routed'] = [{ key: 'X-Edge-Routed', value: 'lambda-edge' }];
request.headers['host'] = [{ key: 'Host', value: 'origin-v2.example.com' }];
}
return request;
};
The deeper architectural differences — cold starts, regional vs colo execution, pricing, and which event hook fires when — are compared directly in Cloudflare Workers vs AWS Lambda@Edge for Request Routing.
Azure, GCP & Fastly
Fastly’s Compute platform exposes a similar request/response model in Rust, JavaScript, or Go, with explicit named backends instead of arbitrary fetch to any host. Azure Front Door Rules Engine and GCP’s Cloud CDN with a routing layer (or a Cloud Function) cover the same ground with declarative rule sets that are less programmable but require no runtime.
// Fastly Compute@Edge (JavaScript)
addEventListener('fetch', (event) => event.respondWith(handle(event.request)));
async function handle(req) {
const url = new URL(req.url);
if (url.pathname.startsWith('/api/v2')) {
const headers = new Headers(req.headers);
headers.set('X-Edge-Routed', 'fastly');
return fetch(req, { backend: 'origin_v2', headers });
}
return new Response('Not Found', { status: 404 });
}
Platform Comparison
| Provider | Mechanism | Wire behavior | Failover / Notes |
|---|---|---|---|
| Cloudflare Workers | Route pattern + fetch handler |
Worker calls fetch() to any HTTPS host; full control over caching |
try/catch around fetch, manual secondary origin; route removal propagates in ~seconds |
| AWS Lambda@Edge | CloudFront behavior + event hook | Mutate request.origin; CloudFront performs the hop |
Origin failover via CloudFront origin groups; deploys replicate globally in minutes |
| Fastly Compute | Named backends + fetch(req,{backend}) |
Must declare backends up front; cannot fetch arbitrary hosts | Backend health + try/catch; very low cold start |
| Azure Front Door | Rules Engine (declarative) | Route/redirect/rewrite via rule conditions | Built-in health probes and priority/weighted origin groups |
| GCP (CDN + Function) | URL maps + optional function | Path-based URL maps; function for dynamic logic | Backend service health checks and failover policies |
Configuration & Operational Procedure
Treat routes as code. The following sequence takes a route from local validation to verified production with a clean rollback path.
- Define the route declaratively. Add the pattern to
wrangler.tomlunder the correct environment block so staging and production never share a routing table:
[[routes]]
pattern = "api.example.com/*"
zone_name = "example.com"
[env.production]
[[env.production.routes]]
pattern = "api.example.com/v2/*"
zone_name = "example.com"
- Validate locally before any edge change.
wrangler devruns the isolate on your machine;--remoteruns it on real Cloudflare infrastructure so you exercise the actualcfobject and TLS behavior:
wrangler dev --remote
- Deploy to staging and list the live routes to confirm the pattern, script, and zone bound as intended:
wrangler deploy --env staging
wrangler routes list --env staging
Expected output:
ID: abc123
Pattern: staging-api.example.com/*
Script: worker-staging
Zone: example.com
- Smoke-test the routed path and confirm your injected headers and cache status appear:
curl -sI https://staging-api.example.com/v2/status -H "X-Debug: true"
Expected headers:
cf-ray: 8a1b2c3d4e5f6789-SJC
x-edge-routed: cf-workers
cf-cache-status: MISS
- Promote to production with the production environment, which applies the production route overrides:
wrangler deploy --env production
- Watch live traffic during the first minutes of the new route with log streaming (see Observability below), then sign the phase off.
The Wrangler CLI enforces strict TOML validation and refuses overlapping nonsense at deploy time. The Dashboard UI, by contrast, lets you save patterns that overlap or shadow each other, which is the single most common source of “my Worker isn’t running” tickets — always prefer the declarative CLI flow so routes live in version control.
Caching, TTL & Propagation Implications
Because a routed Worker runs before the cache, the Worker decides the cache story. Three controls matter:
fetch(url, { cf: { cacheTtl, cacheEverything } })sets edge TTL and forces caching of normally-uncacheable responses. UsecacheEverything: truefor public, static-by-nature paths and a sanecacheTtlso you are not paying origin egress per request.- The Cache API (
caches.default) gives you explicitmatch/putcontrol when you need to key the cache on something other than the URL (a normalized header, a country, an A/B bucket). Normalize before you key, or you fragment the cache into near-useless shards. Cache-Control: no-storeon authenticated or per-user routes keeps private responses out of shared cache entirely.
const ROUTE_REGEX = /^\/docs\/([a-z0-9-]+)\/v(\d+)$/;
export default {
async fetch(request: Request): Promise<Response> {
const match = new URL(request.url).pathname.match(ROUTE_REGEX);
if (!match) return fetch(request);
const [, slug, version] = match;
const primary = `https://docs-origin.example.com/${slug}/v${version}`;
const backup = `https://docs-origin-dr.example.com/${slug}/v${version}`;
try {
const res = await fetch(primary, { cf: { cacheTtl: 300, cacheEverything: true } });
if (res.status >= 500) throw new Error(`origin ${res.status}`);
return res;
} catch (_err) {
try {
return await fetch(backup, { cf: { cacheTtl: 60 } });
} catch {
return new Response('Origin Unavailable', { status: 502 });
}
}
}
};
Two propagation timelines coexist and people conflate them. Route changes (binding or unbinding a pattern) propagate globally within seconds. Worker script changes also deploy in seconds, but any response already in cache stays until its TTL expires or you purge it — so shipping new routing logic does not retroactively change cached responses. Verify the live state with the cf-cache-status header, whose meaningful values are HIT, MISS, EXPIRED, BYPASS, and DYNAMIC.
Observability & Debugging at the Edge
Traffic a Worker terminates never touches your origin access log, so the edge is your only vantage point. Stream logs in real time:
wrangler tail --format pretty --status error
Correlate every edge decision with downstream latency by reading the cf-ray value (it is on every response Cloudflare emits) and logging it alongside your origin’s request ID in your APM. For reproducing edge conditions without shipping, wrangler dev --remote executes against live Cloudflare while proxying to your laptop. For aggregate analysis, the Workers Analytics Engine and Logpush let you slice by cf-cache-status to separate routing problems from caching problems.
A header probe quickly confirms a route is live and behaving:
curl -sI https://api.example.com/v2/status
Expected headers:
cf-ray: 8a1b2c3d4e5f6789-SJC
x-edge-routed: cf-workers
cf-cache-status: HIT
Troubleshooting & Rollback
When a route misbehaves, work from most-disruptive-to-fix backward:
- Worker not running at all. The hostname is likely grey-clouded (DNS-only) or a more specific route is shadowing yours. Run
wrangler routes listand check the orange-cloud status of the DNS record. - Wrong Worker runs. Two patterns overlap and the more specific one is winning (or losing) unexpectedly. Make the intended pattern strictly more specific, or split the path namespaces.
- Infinite loop / 1015 throttling. Your rewrite produces a URL that re-matches the same route, so the Worker calls itself. Rewrite to a hostname not covered by the route, or add a sentinel header and short-circuit when you see it.
- CPU time exceeded. Synchronous work blew the budget. Move work after an early
fetch(), drop heavy regex, and confirm you are on Workers Paid (default 30s wall, configurable) rather than the free tier’s tighter cap.
Rollback. Because Wrangler deploys are versioned, the fastest revert is wrangler rollback to the last known-good deployment. To remove a route entirely, delete it from wrangler.toml and redeploy, or call the API directly:
curl -X DELETE \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes/${ROUTE_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}"
Route removal propagates in seconds; cached responses persist until purged.
Edge Cases & Gotchas
- Overlapping patterns cause silent shadowing — audit for specificity conflicts and document the intended precedence; never rely on declaration order.
- Query strings and scheme are invisible to route matching — enforce HTTPS and gate on query params inside the Worker, not in the pattern.
- Header size ceiling (~16KB total, 8KB practical per-header guidance) — prune and validate before forwarding or origins return
431. request.cfis unavailable inwrangler devwithout--remote— geo and TLS fields will beundefinedlocally, so guard against it.- Subrequest limits — a single Worker invocation is capped (50 subrequests on the free tier, more when paid); a fan-out router can hit it.
- Cache fragmentation from un-normalized keys — lowercase hosts, strip volatile headers, and bucket A/B variants deliberately before keying the Cache API.
Frequently Asked Questions
How does Cloudflare decide which route runs when several patterns match?
It picks the most specific pattern, not the first one declared. A literal path segment outranks a wildcard at the same position and a longer pattern outranks a shorter one, so example.com/api/* always beats example.com/* for an API request.
Can I route to different origins based on request headers?
Yes. Inside the fetch handler you can read Host, User-Agent, a cookie, or an auth token, then build a new Request pointed at the chosen origin before calling fetch(). This is the foundation of header- and identity-based routing at the API Gateway at the Edge layer.
Do Workers routes bypass Cloudflare’s cache?
By default the Worker runs before the cache, but it does not bypass caching unless you tell it to. You control cache reads and writes through fetch() cf options like cacheTtl and cacheEverything, or explicitly via the Cache API.
How do I debug a routing failure in production without touching live traffic?
Stream logs with wrangler tail, correlate the cf-ray header into your APM, and reproduce edge conditions with wrangler dev --remote. Validate the fix in a staging environment with isolated route patterns, then promote with wrangler deploy --env production.