Workers Best Practices·续1
// 🔴 Bad: calling the REST API from a Worker
const badHandler = {
async fetch(request, env) {
const response = await fetch(
"https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/r2/buckets/BUCKET_NAME/objects/my-file",
{ headers: { Authorization: `Bearer ${env.CF_API_TOKEN}` } },
);
return new Response(response.body);
},
};
// ✅ Good: use the binding directly — no network hop, no auth needed
export default {
async fetch(request, env) {
const object = await env.MY_BUCKET.get("my-file");
if (!object) {
return new Response("Not found", { status: 404 });
}
return new Response(object.body, {
headers: {
"Content-Type":
object.httpMetadata?.contentType ?? "application/octet-stream",
},
});
},
};
src/index.tsts
// 🔴 Bad: calling the REST API from a Worker
const badHandler = {
async fetch(request: Request, env: Env): Promise<Response> {
const response = await fetch(
"https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/r2/buckets/BUCKET_NAME/objects/my-file",
{ headers: { Authorization: `Bearer ${env.CF_API_TOKEN}` } },
);
return new Response(response.body);
},
} satisfies ExportedHandler<Env>;
// ✅ Good: use the binding directly — no network hop, no auth needed
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const object = await env.MY_BUCKET.get("my-file");
if (!object) {
return new Response("Not found", { status: 404 });
}
return new Response(object.body, {
headers: {
"Content-Type":
object.httpMetadata?.contentType ?? "application/octet-stream",
},
});
},
} satisfies ExportedHandler<Env>;
Use Queues and Workflows for async and background work
Long-running, retryable, or non-urgent tasks should not block a request. Use Queues and Workflows to move work out of the critical path. They serve different purposes:
Use Queues when you need to decouple a producer from a consumer. Queues are a message broker: one Worker sends a message, another Worker processes it later. They are the right choice for fan-out (one event triggers many consumers), buffering and batching (aggregate messages before writing to a downstream service), and simple single-step background jobs (send an email, fire a webhook, write a log). Queues provide at-least-once delivery with configurable retries per message.
Use Workflows when the background work has multiple steps that depend on each other. Workflows are a durable execution engine: each step's return value is persisted, and if a step fails, only that step is retried — not the entire job. They are the right choice for multi-step processes (charge a card, then create a shipment, then send a confirmation), long-running tasks that need to pause and resume (wait hours or days for an external event or human approval via step.waitForEvent()), and complex conditional logic where later steps depend on earlier results. Workflows can run for hours, days, or weeks.
Use both together when a high-throughput entry point feeds into complex processing. For example, a Queue can buffer incoming orders, and the consumer can create a Workflow instance for each order that requires multi-step fulfillment.
src/index.jsjs
export default {
async fetch(request, env) {
const order = await request.json();
if (order.type === "simple") {
// ✅ Queue: single-step background job — send a message for async processing
await env.ORDER_QUEUE.send({
orderId: order.id,
action: "send-confirmation-email",
});
} else {
// ✅ Workflow: multi-step durable process — payment, fulfillment, notification
const instance = await env.FULFILLMENT_WORKFLOW.create({
params: { orderId: order.id },
});
}
return Response.json({ status: "accepted" }, { status: 202 });
},
};
src/index.tsts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const order = await request.json<{ id: string; type: string }>();
if (order.type === "simple") {
// ✅ Queue: single-step background job — send a message for async processing
await env.ORDER_QUEUE.send({
orderId: order.id,
action: "send-confirmation-email",
});
} else {
// ✅ Workflow: multi-step durable process — payment, fulfillment, notification
const instance = await env.FULFILLMENT_WORKFLOW.create({
params: { orderId: order.id },
});
}
return Response.json({ status: "accepted" }, { status: 202 });
},
} satisfies ExportedHandler<Env>;
For more information, refer to Queues and Workflows.
Use service bindings for Worker-to-Worker communication
When one Worker needs to call another, use service bindings instead of making an HTTP request to a public URL. Service bindings are zero-cost, bypass the public internet, and support type-safe RPC.
src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";
// The "auth" Worker exposes RPC methods
export class AuthService extends WorkerEntrypoint {
async verifyToken(token) {
// Token verification logic
return { userId: "user-123", valid: true };
}
}
// The "api" Worker calls the auth Worker via a service binding
export default {
async fetch(request, env) {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
// ✅ Good: call another Worker via service binding RPC — no network hop
const auth = await env.AUTH_SERVICE.verifyToken(token);
if (!auth.valid) {
return new Response("Invalid token", { status: 403 });
}
return Response.json({ userId: auth.userId });
},
};
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";
// The "auth" Worker exposes RPC methods
export class AuthService extends WorkerEntrypoint {
async verifyToken(
token: string,
): Promise<{ userId: string; valid: boolean }> {
// Token verification logic
return { userId: "user-123", valid: true };
}
}
// The "api" Worker calls the auth Worker via a service binding
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
// ✅ Good: call another Worker via service binding RPC — no network hop
const auth = await env.AUTH_SERVICE.verifyToken(token);
if (!auth.valid) {
return new Response("Invalid token", { status: 403 });
}
return Response.json({ userId: auth.userId });
},
} satisfies ExportedHandler<Env>;
Use Hyperdrive for external database connections
Always use Hyperdrive when connecting to a remote PostgreSQL or MySQL database from a Worker. Hyperdrive maintains a regional connection pool close to your database, eliminating the per-request cost of TCP handshake, TLS negotiation, and connection setup. It also caches query results where possible.
Create a new Client on each request. Hyperdrive manages the underlying pool, so client creation is fast. Requires nodejs_compat for database driver support.
{
"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"],
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<YOUR_HYPERDRIVE_ID>" }],
}
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" ]
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "<YOUR_HYPERDRIVE_ID>"
src/index.jsjs
import { Client } from "pg";
export default {
async fetch(request, env) {
// ✅ Good: create a new client per request — Hyperdrive pools the underlying connection
const client = new Client({
connectionString: env.HYPERDRIVE.connectionString,
});
try {
await client.connect();
const result = await client.query("SELECT id, name FROM users LIMIT 10");
return Response.json(result.rows);
} catch (e) {
console.error(
JSON.stringify({ message: "database query failed", error: String(e) }),
);
return Response.json({ error: "Database error" }, { status: 500 });
}
},
};
// 🔴 Bad: connecting directly to a remote database without Hyperdrive
// Every request pays the full TCP + TLS + auth cost (often 300-500ms)
const badHandler = {
async fetch(request, env) {
const client = new Client({
connectionString: "postgres://user:pass@db.example.com:5432/mydb",
});
await client.connect();
const result = await client.query("SELECT id, name FROM users LIMIT 10");
return Response.json(result.rows);
},
};
src/index.tsts
import { Client } from "pg";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// ✅ Good: create a new client per request — Hyperdrive pools the underlying connection
const client = new Client({
connectionString: env.HYPERDRIVE.connectionString,
});
try {
await client.connect();
const result = await client.query("SELECT id, name FROM users LIMIT 10");
return Response.json(result.rows);
} catch (e) {
console.error(
JSON.stringify({ message: "database query failed", error: String(e) }),
);
return Response.json({ error: "Database error" }, { status: 500 });
}
},
} satisfies ExportedHandler<Env>;
// 🔴 Bad: connecting directly to a remote database without Hyperdrive
// Every request pays the full TCP + TLS + auth cost (often 300-500ms)
const badHandler = {
async fetch(request: Request, env: Env): Promise<Response> {
const client = new Client({
connectionString: "postgres://user:pass@db.example.com:5432/mydb",
});
await client.connect();
const result = await client.query("SELECT id, name FROM users LIMIT 10");
return Response.json(result.rows);
},
} satisfies ExportedHandler<Env>;
For more information, refer to Hyperdrive.
Use Durable Objects for WebSockets
Plain Workers can upgrade HTTP connections to WebSockets, but they lack persistent state and hibernation. If the isolate is evicted, the connection is lost because there is no persistent actor to hold it. For reliable, long-lived WebSocket connections, use Durable Objects with the Hibernation API. Durable Objects keep WebSocket connections open even while the object is evicted from memory, and automatically wake up when a message arrives.
Use this.ctx.acceptWebSocket() instead of ws.accept() to enable hibernation. Use setWebSocketAutoResponse for ping/pong heartbeats that do not wake the object.
src/index.jsjs
import { DurableObject } from "cloudflare:workers";
// Parent Worker: upgrades HTTP to WebSocket and routes to a Durable Object
export default {
async fetch(request, env) {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const stub = env.CHAT_ROOM.getByName("default-room");
return stub.fetch(request);
},
};
// Durable Object: manages WebSocket connections with hibernation
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// Auto ping/pong without waking the object
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong"),
);
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// ✅ Good: acceptWebSocket enables hibernation
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
// Called when a message arrives — the object wakes from hibernation if needed
async webSocketMessage(ws, message) {
for (const conn of this.ctx.getWebSockets()) {
conn.send(typeof message === "string" ? message : "binary");
}
}
async webSocketClose(ws, code, reason, wasClean) {
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
// auto-replies to Close frames. Calling close() is safe but no longer required.
ws.close(code, reason);
}
}
src/index.tsts
import { DurableObject } from "cloudflare:workers";
// Parent Worker: upgrades HTTP to WebSocket and routes to a Durable Object
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const stub = env.CHAT_ROOM.getByName("default-room");
return stub.fetch(request);
},
} satisfies ExportedHandler<Env>;
// Durable Object: manages WebSocket connections with hibernation
export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Auto ping/pong without waking the object
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong"),
);
}
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// ✅ Good: acceptWebSocket enables hibernation
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
// Called when a message arrives — the object wakes from hibernation if needed
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const conn of this.ctx.getWebSockets()) {
conn.send(typeof message === "string" ? message : "binary");
}
}
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean,
) {
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
// auto-replies to Close frames. Calling close() is safe but no longer required.
ws.close(code, reason);
}
}
For more information, refer to Durable Objects WebSocket best practices.
Use Workers Static Assets for new projects
Workers Static Assets is the recommended way to deploy static sites, single-page applications, and full-stack apps on Cloudflare. If you are starting a new project, use Workers instead of Pages. Pages continues to work, but new features and optimizations are focused on Workers.
For a purely static site, point assets.directory at your build output. No Worker script is needed. For a full-stack app, add a main entry point and an ASSETS binding to serve static files alongside your API.
{
// Static site — no Worker script needed
"name": "my-static-site",
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
},
}
name = "my-static-site"
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]
[assets]
directory = "./dist"
For more information, refer to Workers Static Assets.
Observability
Enable Workers Logs and Traces
Production Workers without observability are a black box. Enable logs and traces before you deploy to production. When an intermittent error appears, you need data already being collected to diagnose it.
Enable them in your Wrangler configuration and use head_sampling_rate to control volume and manage costs. A sampling rate of 1 captures everything; lower it for high-traffic Workers.
Use structured JSON logging with console.log so logs are searchable and filterable. Use console.error for errors and console.warn for warnings. These appear at the correct severity level in the Workers Observability dashboard.
{
"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"],
"observability": {
"enabled": true,
"logs": {
// Capture 100% of logs — lower this for high-traffic Workers
"head_sampling_rate": 1,
},
"traces": {
"enabled": true,
"head_sampling_rate": 0.01, // Sample 1% of traces
},
},
}
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" ]
[observability]
enabled = true
[observability.logs]
head_sampling_rate = 1
[observability.traces]
enabled = true
head_sampling_rate = 0.01
src/index.jsjs
```js export default { async fetch(request, env) { const url = new URL(request.url);
try { // ✅ Good: structured JSON — searchable and filterable in the dashboard console.log( JSON.stringify({ message: "incoming request", method: request.method, path: url.pathname, }), );
