After this lesson you'll be able to attach a Worker to a zone the right way (route vs. custom domain), reason about which of several overlapping routes wins, and structure request handling inside a single fetch handler the way Express middleware would, including an auth-check-then-proxy example and calling a second Worker via a Service Binding.
A freshly deployed Worker is reachable at a *.workers.dev URL by default. Getting it to answer for api.yoursite.com instead — and deciding what happens when a request hits /admin/* versus everything else — is a separate, Cloudflare-specific problem from writing the Worker's logic. This lesson covers the three ways a Worker gets attached to a hostname, how Workers do "middleware" without an Express-style framework, and how one Worker calls another directly.
There are three distinct mechanisms for making a Worker reachable, and they are not interchangeable:
<worker-name>.<your-subdomain>.workers.dev. Cloudflare's own docs call this suitable for "personal or hobby projects that aren't business-critical," not production traffic.api.example.com/v1/*) mapped to a Worker on a zone you already have DNS records for. The Worker sits in front of an existing origin server and typically proxies to it.api.example.com) where Cloudflare manages the DNS record and TLS certificate for you, and the Worker itself is treated as the origin — there's no separate backend behind it required.Separately, Service Bindings let one Worker call another Worker directly — server-side, without a public HTTP round trip — which is how you compose multiple Workers into one system instead of one giant Worker.
A route is a wildcard pattern plus a Worker name, configured either in the dashboard (Workers & Pages → your Worker → Settings → Domains & Routes) or in wrangler.jsonc:
{
"routes": [
{ "pattern": "api.example.com/v1/*", "zone_name": "example.com" }
]
}
Route patterns only support * as a wildcard, and it can't appear mid-string — *.jpg or /foo?bar=* are invalid, but api.example.com/* is fine. The zone must already have Cloudflare-managed DNS; a route doesn't create DNS records for you, so a hostname with no DNS record still won't resolve. Routes assume you have (or will have) an origin server behind Cloudflare — the Worker is middleware in front of it, not necessarily the whole backend.
A Custom Domain is simpler operationally: Cloudflare creates the DNS record and issues the certificate for you, and the entire hostname points at the Worker with no wildcard pattern to reason about. The tradeoff is exactness — the incoming request's hostname must match exactly (no wildcards), you can't put a Custom Domain on a hostname that already has a CNAME record, and root domain vs. www need two separate Custom Domains if you want both. Configure it the same way, with one extra flag:
{
"routes": [
{ "pattern": "api.example.com", "custom_domain": true }
]
}
Rule of thumb: reach for a Custom Domain when the Worker is the entire backend for that hostname. Reach for a Route when the Worker needs to sit in front of, or beside, an existing origin — for example, running auth or A/B logic on some paths while letting everything else fall through to your current server's DNS record.
Workers don't ship an Express-style app.use() chain. A Worker is one exported fetch(request, env, ctx) function, so "middleware" is a pattern you build yourself: a pipeline of functions that each take a request (and accumulated context) and either short-circuit with a Response or hand off to the next step.
type Handler = (req: Request, ctx: { env: Env }) => Promise<Response | null>;
// Each "middleware" returns a Response to short-circuit,
// or null to let the chain continue.
const withAuth: Handler = async (req, { env }) => {
const token = req.headers.get("Authorization");
if (!token || !(await isValid(token, env))) {
return new Response("Unauthorized", { status: 401 });
}
return null; // continue
};
const withLogging: Handler = async (req) => {
console.log(`${req.method} ${req.url}`);
return null;
};
async function runChain(handlers: Handler[], req: Request, ctx: { env: Env }) {
for (const handler of handlers) {
const result = await handler(req, ctx);
if (result) return result; // short-circuit
}
return null; // fall through to main logic
}
This is just function composition — nothing Workers-specific about the pattern itself, but it's worth naming explicitly because developers coming from Node frameworks often go looking for a middleware API that doesn't exist here. If you want a routing/middleware layer with less boilerplate, community frameworks (Hono, itty-router) implement this same chain-of-handlers idea with a nicer API on top of the same single fetch entry point.
A common real shape: the Worker sits on a Route in front of an existing origin. It rejects unauthenticated requests immediately, then forwards everything else on with a header the origin can trust.
interface Env {
ORIGIN_URL: string;
API_KEYS: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// --- "middleware" step 1: auth check ---
const apiKey = request.headers.get("X-API-Key");
if (!apiKey) {
return new Response("Missing X-API-Key header", { status: 401 });
}
const isValidKey = await env.API_KEYS.get(apiKey);
if (!isValidKey) {
return new Response("Invalid API key", { status: 403 });
}
// --- "middleware" step 2: simple request logging ---
console.log(`${request.method} ${new URL(request.url).pathname} — key ${apiKey.slice(0, 6)}…`);
// --- final step: proxy to origin ---
const originUrl = new URL(request.url);
originUrl.hostname = new URL(env.ORIGIN_URL).hostname;
const proxiedRequest = new Request(originUrl.toString(), request);
proxiedRequest.headers.set("X-Forwarded-By", "edge-auth-worker");
return fetch(proxiedRequest);
},
};
This Worker would sit on a Route like api.example.com/* in front of the real origin's DNS record — it is deliberately not a Custom Domain, because it isn't the backend itself, it's a gatekeeper for one.
When you split logic across multiple Workers (say, an edge-facing Worker and an internal "billing" Worker), a Service Binding lets one call the other directly — RPC-style, without a public HTTP hop, and without exposing the second Worker's URL at all.
// wrangler.jsonc for the CALLING worker
{
"services": [
{ "binding": "BILLING", "service": "billing-worker" }
]
}
// billing-worker: exposes methods via RPC
import { WorkerEntrypoint } from "cloudflare:workers";
export default class Billing extends WorkerEntrypoint {
async chargeCard(customerId: string, cents: number) {
// ... real charge logic
return { charged: cents, customerId };
}
}
// caller worker: env.BILLING is the binding name from wrangler.jsonc
export default {
async fetch(request: Request, env: Env) {
const result = await env.BILLING.chargeCard("cus_123", 4200);
return Response.json(result);
},
};
Always await a service binding call — an un-awaited call can have its Worker torn down before it finishes. Service bindings are the composition tool for multi-Worker systems; reach for them instead of one Worker calling another's public URL over fetch(), which adds a real network hop and exposes an endpoint you didn't need to expose.
Routes, Custom Domains, and Service Bindings are configuration, not billed line items — you don't pay extra to attach a Worker to a zone or to call another Worker via a binding. What you do pay for is the underlying Workers request and CPU-time usage, on Cloudflare's standard Workers plans:
A Service Binding call runs in the same invocation's CPU-time budget as the caller in most cases (Workers docs describe it as near-zero overhead since both Workers typically run on the same thread), so chaining Workers via bindings is cheap relative to an external HTTP hop — but treat the specifics above as a snapshot, and check the live pricing page for exact current numbers before making a cost commitment.
/api/* on a domain whose /* (everything else) is served by an existing CMS or static site behind the same zone.api.yoursaas.com where the Worker handles every request with no other origin involved.www.example.com/* beats *.example.com/* for a request to www.example.com, regardless of which one you added first or which Worker "feels" like it should own that traffic. If two engineers on a team each add a route for overlapping patterns pointing at different Workers, the one that "wins" depends on specificity, not intent — audit the full route list for a zone (Dashboard → zone → Workers Routes, or wrangler deployments / the API) whenever a request seems to be hitting the wrong Worker. Also remember a route with no Worker attached can intentionally exclude a sub-pattern from a broader route.
Cloudflare Docs — Routes is the canonical reference for route pattern syntax and precedence rules; see also Custom Domains and Service Bindings for the mechanics covered above, and the Workers pricing page for current numbers (checked 2026-07-03).
*.example.com/* pointing at Worker A, and www.example.com/* pointing at Worker B. A request comes in for www.example.com/hello. Which Worker handles it?fetch() call to another Worker's URL?A Route maps a URL pattern to a Worker on a zone whose DNS you manage yourself — the Worker is typically middleware in front of an existing origin. A Custom Domain hands DNS and certificate management to Cloudflare and treats the Worker itself as the origin for that exact hostname, with no wildcard matching.
Use a Service Binding instead of a public fetch() call when one Worker needs to call another privately: it avoids a real network hop, doesn't require the target Worker to be publicly reachable at all, and lets you call typed methods (via RPC) instead of shaping an HTTP request.