Workers Best Practices
Last updated Oct 12, 2026 Best practices for Workers based on production patterns, Cloudflare's own internal usage, and common issues seen across the developer community.
Configuration
Keep your compatibility date current
The compatibility_date controls which runtime features and bug fixes are available to your Worker. Setting it to today's date on new projects ensures you get the latest behavior. Periodically updating it on existing projects gives you access to new APIs and fixes without changing your code.
{
"name": "my-worker",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
For more information, refer to Compatibility dates.
Enable nodejs_compat
The nodejs_compat compatibility flag gives your Worker access to Node.js built-in modules like node:crypto, node:buffer, node:stream, and others. Many libraries depend on these modules, and enabling this flag avoids cryptic import errors at runtime.
{
"name": "my-worker",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
For more information, refer to Node.js compatibility.
Enable source maps
Enable source maps for every JavaScript or TypeScript Worker. Captured exception stack traces then point to your original files and lines.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"upload_source_maps": true
}
upload_source_maps = true
If a framework or custom build tool bundles your Worker, enable external source maps in its server build too. With Vite, configure the Worker environment's build.sourcemap setting. Keep server maps out of public assets.
Generate binding types with wrangler types
Do not hand-write your Env interface. Run wrangler types to generate a type definition file that matches your actual Wrangler configuration. This catches mismatches between your config and code at compile time instead of at deploy time.
Re-run wrangler types whenever you add or rename a binding.
npmyarnpnpm
npx wrangler types
yarn wrangler types
pnpm wrangler types
src/index.jsjs
// ✅ Good: Env is generated by wrangler types and always matches your config
// Do not manually define Env — it drifts from your actual bindings
export default {
async fetch(request, env) {
// env.MY_KV, env.MY_BUCKET, etc. are all correctly typed
const value = await env.MY_KV.get("key");
return new Response(value);
},
};
src/index.tsts
// ✅ Good: Env is generated by wrangler types and always matches your config
// Do not manually define Env — it drifts from your actual bindings
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// env.MY_KV, env.MY_BUCKET, etc. are all correctly typed
const value = await env.MY_KV.get("key");
return new Response(value);
},
} satisfies ExportedHandler<Env>;
For more information, refer to wrangler types.
Store secrets with wrangler secret, not in source
Secrets (API keys, tokens, database credentials) must never appear in your Wrangler configuration or source code. Use wrangler secret put to store them securely, and access them through env at runtime. For local development, use a .env file (and make sure it is in your .gitignore). For more information, refer to Environment variables.
{
"name": "my-worker",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
// ✅ Good: non-secret configuration lives in version control
"vars": {
"API_BASE_URL": "https://api.example.com",
},
// 🔴 Bad: never put secrets here
// "API_KEY": "sk-live-abc123..."
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
[vars]
API_BASE_URL = "https://api.example.com"
To add a secret, run the following command and provide the secret interactively when prompted:
npmyarnpnpm
npx wrangler secret put API_KEY
yarn wrangler secret put API_KEY
pnpm wrangler secret put API_KEY
You can also pipe secrets from other tools or environment variables:
# Pipe from another CLI tool
npx <some-cli-tool> --get-secret | npx wrangler secret put API_KEY
# Pipe from an environment variable or .env file
echo "$API_KEY" | npx wrangler secret put API_KEY
For more information, refer to Secrets.
Configure environments deliberately
Wrangler environments let you deploy the same code to separate Workers for production, staging, and development. Each environment creates a distinct Worker named {name}-{env} (for example, my-api-production and my-api-staging).
Each environment is treated separately. Bindings and vars need to be declared per environment and are not inherited. Refer to non-inheritable keys. The root Worker (without an environment suffix) is a separate deployment. If you do not intend to use it, do not deploy without specifying an environment using --env.
{
"name": "my-api",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
// This binding only applies to the root Worker
"kv_namespaces": [{ "binding": "CACHE", "id": "dev-kv-id" }],
"env": {
// Production environment: deploys as "my-api-production"
"production": {
"kv_namespaces": [{ "binding": "CACHE", "id": "prod-kv-id" }],
"routes": [
{ "pattern": "api.example.com/*", "zone_name": "example.com" },
],
},
// Staging environment: deploys as "my-api-staging"
"staging": {
"kv_namespaces": [{ "binding": "CACHE", "id": "staging-kv-id" }],
"routes": [
{ "pattern": "api-staging.example.com/*", "zone_name": "example.com" },
],
},
},
}
name = "my-api"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
[[kv_namespaces]]
binding = "CACHE"
id = "dev-kv-id"
[[env.production.kv_namespaces]]
binding = "CACHE"
id = "prod-kv-id"
[[env.production.routes]]
pattern = "api.example.com/*"
zone_name = "example.com"
[[env.staging.kv_namespaces]]
binding = "CACHE"
id = "staging-kv-id"
[[env.staging.routes]]
pattern = "api-staging.example.com/*"
zone_name = "example.com"
With this configuration file, to deploy to staging:
npmyarnpnpm
npx wrangler deploy --env staging
yarn wrangler deploy --env staging
pnpm wrangler deploy --env staging
For more information, refer to Environments.
Set up custom domains or routes correctly
Workers support two routing mechanisms, and they serve different purposes:
- Custom domains: The Worker is the origin. Cloudflare creates DNS records and SSL certificates automatically. Use this when your Worker handles all traffic for a hostname.
- Routes: The Worker runs in front of an existing origin server. You must have a Cloudflare proxied (orange-clouded) DNS record for the hostname before adding a route.
The most common mistake with routes is missing the DNS record. Without a proxied DNS record, requests to the hostname return ERR_NAME_NOT_RESOLVED and never reach your Worker. If you do not have a real origin, add a proxied AAAA record pointing to 100:: as a placeholder.
{
"name": "my-worker",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
// Option 1: Custom domain — Worker is the origin, DNS is managed automatically
"routes": [{ "pattern": "api.example.com", "custom_domain": true }],
// Option 2: Route — Worker runs in front of an existing origin
// Requires a proxied DNS record for shop.example.com
// "routes": [
// { "pattern": "shop.example.com/*", "zone_name": "example.com" }
// ]
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
[[routes]]
pattern = "api.example.com"
custom_domain = true
For more information, refer to Routing.
Request and response handling
Stream request and response bodies
Regardless of memory limits, streaming large requests and responses is a best practice in any language. It reduces peak memory usage and improves time-to-first-byte. Workers have a 128 MB memory limit, so buffering an entire body with await response.text() or await request.arrayBuffer() will crash your Worker on large payloads.
For request bodies you do consume entirely (JSON payloads, file uploads), enforce a maximum size before reading. This prevents clients from sending data you do not want to process.
Stream data through your Worker using TransformStream to pipe from a source to a destination without holding it all in memory.
src/index.jsjs
// 🔴 Bad: buffers the entire response body in memory
const badHandler = {
async fetch(request, env) {
const response = await fetch("https://api.example.com/large-dataset");
const text = await response.text();
return new Response(text);
},
};
// ✅ Good: stream the response body through without buffering
export default {
async fetch(request, env) {
const response = await fetch("https://api.example.com/large-dataset");
return new Response(response.body, response);
},
};
src/index.tsts
// 🔴 Bad: buffers the entire response body in memory
const badHandler = {
async fetch(request: Request, env: Env): Promise<Response> {
const response = await fetch("https://api.example.com/large-dataset");
const text = await response.text();
return new Response(text);
},
} satisfies ExportedHandler<Env>;
// ✅ Good: stream the response body through without buffering
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const response = await fetch("https://api.example.com/large-dataset");
return new Response(response.body, response);
},
} satisfies ExportedHandler<Env>;
When you need to concatenate multiple responses (for example, fetching data from several upstream APIs), pipe each body sequentially into a single writable stream. This avoids buffering any of the responses in memory.
src/concat.jsjs
export default {
async fetch(request, env) {
const urls = [
"https://api.example.com/part-1",
"https://api.example.com/part-2",
"https://api.example.com/part-3",
];
const { readable, writable } = new TransformStream();
// ✅ Good: pipe each response body sequentially without buffering
const pipeline = (async () => {
for (const url of urls) {
const response = await fetch(url);
if (response.body) {
// pipeTo with preventClose keeps the writable open for the next response
await response.body.pipeTo(writable, {
preventClose: true,
});
}
}
await writable.close();
})();
// Return the readable side immediately — data streams as it arrives
return new Response(readable, {
headers: { "Content-Type": "application/octet-stream" },
});
},
};
src/concat.tsts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const urls = [
"https://api.example.com/part-1",
"https://api.example.com/part-2",
"https://api.example.com/part-3",
];
const { readable, writable } = new TransformStream();
// ✅ Good: pipe each response body sequentially without buffering
const pipeline = (async () => {
for (const url of urls) {
const response = await fetch(url);
if (response.body) {
// pipeTo with preventClose keeps the writable open for the next response
await response.body.pipeTo(writable, {
preventClose: true,
});
}
}
await writable.close();
})();
// Return the readable side immediately — data streams as it arrives
return new Response(readable, {
headers: { "Content-Type": "application/octet-stream" },
});
},
} satisfies ExportedHandler<Env>;
For more information, refer to Streams.
Use waitUntil for work after the response
ctx.waitUntil() lets you perform work after the response is sent to the client, such as analytics, cache writes, logging, or webhook notifications. This keeps your response fast while still completing background tasks.
Use ctx.waitUntil() only for work that does not affect the response. If the response depends on the work, await it before returning the response or stream the response as the work completes. A Worker that is still streaming a response body remains active without ctx.waitUntil().
There are two common pitfalls: destructuring ctx (which loses the this binding and throws "Illegal invocation"), and exceeding the 30-second waitUntil() time limit after the response is sent or the client disconnects.
src/index.jsjs
// 🔴 Bad: destructuring ctx loses the `this` binding
const badHandler = {
async fetch(request, env, ctx) {
const { waitUntil } = ctx; // "Illegal invocation" at runtime
waitUntil(fetch("https://analytics.example.com/events"));
return new Response("OK");
},
};
// ✅ Good: send the response immediately, do background work after
export default {
async fetch(request, env, ctx) {
const data = await processRequest(request);
ctx.waitUntil(logToAnalytics(env, data));
ctx.waitUntil(updateCache(env, data));
return Response.json(data);
},
};
async function logToAnalytics(env, data) {
await fetch("https://analytics.example.com/events", {
method: "POST",
body: JSON.stringify(data),
});
}
async function updateCache(env, data) {
await env.CACHE.put("latest", JSON.stringify(data));
}
src/index.tsts
// 🔴 Bad: destructuring ctx loses the `this` binding
const badHandler = {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const { waitUntil } = ctx; // "Illegal invocation" at runtime
waitUntil(fetch("https://analytics.example.com/events"));
return new Response("OK");
},
} satisfies ExportedHandler<Env>;
// ✅ Good: send the response immediately, do background work after
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const data = await processRequest(request);
ctx.waitUntil(logToAnalytics(env, data));
ctx.waitUntil(updateCache(env, data));
return Response.json(data);
},
} satisfies ExportedHandler<Env>;
async function logToAnalytics(env: Env, data: unknown): Promise<void> {
await fetch("https://analytics.example.com/events", {
method: "POST",
body: JSON.stringify(data),
});
}
async function updateCache(env: Env, data: unknown): Promise<void> {
await env.CACHE.put("latest", JSON.stringify(data));
}
For more information, refer to Context.
Architecture
Use bindings for Cloudflare services, not REST APIs
Some Cloudflare services like R2, KV, D1, Queues, and Workflows are available as bindings. Bindings are direct, in-process references that require no network hop, no authentication, and no extra latency. Using the REST API from within a Worker wastes time and adds unnecessary complexity.
src/index.jsjs
