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.
Routes, Custom Domains and workers.dev
Cloudflare gives you three different ways to put a hostname in front of a Worker, and they behave differently enough that picking the wrong one produces symptoms that look like a routing bug.
A route attaches a Worker to a pattern on a zone you already own. The zone keeps its existing DNS record and its existing proxy behavior; the Worker is inserted into the request path. Because the record still has to exist and still has to be proxied, a route on a hostname with no DNS record at all resolves to nothing and the Worker never sees traffic — the route is bound, the zone is correct, and the pattern is simply unreachable.
A Custom Domain is different: Cloudflare creates and manages the DNS record for you and points it directly at the Worker, with no origin behind it. That is the right choice when the Worker is the application rather than a layer in front of one, because it removes an entire class of “which record is this proxied through” questions. The trade-off is that a Custom Domain hostname cannot also serve an origin — there is nothing to fall through to, so an unmatched path returns whatever your handler returns, not a 404 from a backend.
A workers.dev subdomain is neither. It is a free hostname on Cloudflare’s own zone, it is not covered by your zone’s WAF rules, page rules, or cache settings, and its request.cf payload is populated but its analytics live separately. Treat it as a scratch endpoint for smoke tests and disable it in production with workers_dev = false in wrangler.toml, otherwise you leave a copy of your routing logic reachable on a hostname that bypasses every zone-level protection you configured.
One more asymmetry worth internalizing: a wildcard is only meaningful at the end of a pattern or as a leading subdomain label. example.com/api/*/v2 does not mean “any tenant segment” — the middle wildcard is not a general glob, and patterns that try to use it match far more or far less than the author expected. When you need a middle segment to vary, match a broad prefix in the route and do the precise discrimination inside the isolate with URLPattern or a regular expression, where you can actually test it.
Bindings, Environments and Secrets
Routing logic almost always needs configuration: which origin, which tenant map, which feature flag. Cloudflare exposes that as bindings — objects injected into the env argument of fetch at isolate construction time, with no network round trip on the request path.
name = "api-router"
main = "src/index.ts"
compatibility_date = "2024-09-23"
workers_dev = false
[vars]
DEFAULT_ORIGIN = "https://origin-stable.example.com"
[[kv_namespaces]]
binding = "ROUTE_TABLE"
id = "8f2a4c1b9d3e5f6071829304a5b6c7d8"
[env.production.vars]
DEFAULT_ORIGIN = "https://origin-prod.example.com"
Three binding types cover nearly every routing need. Plain vars are build-time constants baked into the deployment — changing one is a redeploy, which is exactly what you want for anything that should be reviewed. A KV namespace holds data you want to change without a deploy, such as a tenant-to-origin map; reads are eventually consistent with a propagation window measured in seconds to a minute, so never use KV for anything where a stale read is a correctness bug. Secrets, set with wrangler secret put SIGNING_KEY, are encrypted at rest, never appear in wrangler.toml, and are the only acceptable home for credentials the Worker forwards upstream.
Environment isolation is the discipline that keeps all of this honest. Every [env.<name>] block is a wholly separate deployment with its own script name, its own routes, and its own bindings — a staging Worker cannot accidentally read the production KV namespace because the binding simply resolves to a different id. Deploy with wrangler deploy --env staging, and make the environment name visible in the response so you can prove which build answered:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const mapped = await env.ROUTE_TABLE.get(url.hostname);
const origin = mapped ?? env.DEFAULT_ORIGIN;
const res = await fetch(new Request(origin + url.pathname + url.search, request));
const out = new Response(res.body, res);
out.headers.set('x-edge-env', env.ENVIRONMENT ?? 'unknown');
out.headers.set('x-edge-origin', mapped ? 'kv' : 'default');
return out;
}
};
The x-edge-origin header is a small thing that pays for itself the first time a tenant reports being sent to the wrong backend: one curl -I tells you whether the KV lookup hit or whether the code fell through to the default, which cuts the search space in half before you open a log.
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.
The operational consequence is that “did my change take effect?” is two questions, not one. Confirm the control-plane half by re-reading the live route list and the deployment id; confirm the data-plane half by fetching a representative URL with a cache-busting query string and comparing it against the same URL without one. If the busted request shows new behavior and the plain request shows old behavior, nothing is broken — you are looking at a cached body, and the fix is a targeted purge rather than another deploy.
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. Error 1101— Worker threw an uncaught exception. The isolate crashed before returning aResponse, so Cloudflare serves its own error page and your origin is never contacted. Reproduce it by replaying the failing URL throughwrangler dev --remote; the stack trace names the line. Wrap the whole handler body in atry/catchthat falls through tofetch(request)so a code defect degrades to pass-through instead of a hard outage.Error 1102— resource limits exceeded during startup. Distinct from a CPU timeout: this fires when top-level module code (a large constant table, an eagerJSON.parse, a regex compiled at import time) is too expensive to evaluate when the isolate is constructed. Move the work behind a lazily initialized module-scope variable so it runs on the first request instead of on every isolate creation.- The route matches but a stale script answers. You deployed to the default environment while the route lives under
[env.production], so the pattern is still bound to the previous script.wrangler deployments list --env productionshows the active version id; compare it against the id printed by your last deploy before assuming a caching problem.
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.
- A route bound to a hostname with no DNS record never fires and produces no error anywhere — the pattern is valid, the zone is right, and the name simply does not resolve. Check the record before you check the code.
workers.devleft enabled in production exposes the same routing logic on a hostname outside your zone, where none of your WAF rules, cache settings or rate limits apply. Setworkers_dev = falseonce real routes exist.- KV reads are eventually consistent. A write is visible immediately in the PoP that performed it and within roughly a minute elsewhere, so a routing table updated during an incident does not flip every region at the same instant. Plan the change so both the old and new values are safe to serve during that window.
request.bodycan only be read once. Consuming it for an inspection and then passing the originalrequesttofetchyields an empty upstream body; clone first, or read into a variable and construct the outboundRequestwith that value.- Response objects from
fetchhave immutable headers. Mutating them throws; constructnew Response(res.body, res)and edit the copy, which is why every snippet above does exactly that.
Performance Budget and Subrequest Accounting
Routing code lives on the hot path of every matched request, so the interesting number is not how fast it can run once but how much headroom it leaves under load. Workers bill and limit on CPU time, not wall-clock, which is the detail that makes edge routing economical: the milliseconds an isolate spends awaiting an origin fetch cost you nothing and count against nothing. A handler that waits 400 ms for a slow backend but only computes for 2 ms is a 2 ms handler as far as the platform is concerned.
What does consume the budget is anything synchronous: regular expressions with backtracking, parsing a large JSON blob on every request, base64 work, or building strings in a loop. The practical technique is to hoist immutable work to module scope so it is paid once per isolate rather than once per request — but only if it is cheap enough not to trip the startup limit described above.
// Compiled once per isolate, not once per request.
const TENANT_RE = /^\/t\/([a-z0-9][a-z0-9-]{1,38})\//;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const tenant = TENANT_RE.exec(url.pathname)?.[1];
if (!tenant) return fetch(request);
const origin = await env.ROUTE_TABLE.get(`tenant:${tenant}`);
if (!origin) return new Response('Unknown tenant', { status: 404 });
const target = origin + url.pathname.replace(`/t/${tenant}`, '') + url.search;
return fetch(new Request(target, request));
}
};
Subrequests are the other ceiling. Every outbound fetch, every KV read, and every Cache API lookup counts as a subrequest against a per-invocation cap — 50 on the free tier and higher on paid plans. A router that fans out to three backends and reads two KV keys is fine; one that loops over a list of candidate origins until something answers is one bad configuration away from exhausting the budget and returning 1015. Bound the fan-out explicitly with a counter, and prefer a single KV read of a whole map over many reads of individual keys.
Finally, remember that a Worker holds the connection open for as long as its response body streams. ctx.waitUntil() exists precisely so that logging, analytics writes and cache warming happen after the response is on the wire rather than in front of it — moving one analytics POST into waitUntil is frequently worth more p99 improvement than any amount of micro-optimizing the routing logic itself.
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.
Should the routing table live in code or in KV?
Put anything that needs review in code as a vars entry, because changing it then requires a pull request and leaves an audit trail. Put anything you may need to change during an incident in KV, and keep the in-code table as the fallback when the KV read returns null — that way an empty or unreachable namespace degrades to the last reviewed configuration rather than to a 404.
Does a Worker route work on a hostname without a DNS record? No, and the failure is completely silent. A route only inserts the Worker into an existing proxied request path, so if the hostname does not resolve there is no request to intercept. Either create a proxied record for the name or attach the Worker as a Custom Domain, which creates the record for you.