workers
Cloudflare Workers
Essential patterns for building Cloudflare Workers applications with TypeScript, proper configuration, and Service Bindings for microservices.
FIRST: Project Setup
Initialize a new Workers project:
npm create cloudflare@latest my-worker
# OR
wrangler init my-worker
Minimal wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-03-07",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true,
"head_sampling_rate": 1
}
}
Code Standards
| Standard | Requirement | Notes |
|---|---|---|
| Language | TypeScript by default | JavaScript only if explicitly requested |
| Module Format | ES modules only | NEVER use Service Worker format |
| Imports | Always import types/classes | Must import all used methods |
| File Structure | Single file unless specified | Keep code in one file by default |
| Dependencies | Minimize external deps | Use official SDKs when available |
| Native Bindings | Not supported | Avoid FFI/C bindings |
| Types | Include TypeScript types | Define Env interface for bindings |
Handler Patterns
HTTP Request Handler (fetch)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Route handling
if (url.pathname === "/api/data") {
return handleAPI(request, env);
}
return new Response("Hello World!", {
headers: { "Content-Type": "text/plain" }
});
}
};
async function handleAPI(request: Request, env: Env): Promise<Response> {
// Validate request method
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
try {
const data = await request.json();
// Process data...
return Response.json({ success: true, data });
} catch (error) {
return Response.json(
{ error: "Invalid JSON" },
{ status: 400 }
);
}
}
Scheduled Handler (cron)
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
// Run scheduled tasks
console.log("Cron triggered:", new Date(event.scheduledTime).toISOString());
// Use waitUntil for background work
ctx.waitUntil(performCleanup(env));
}
};
async function performCleanup(env: Env): Promise<void> {
// Background cleanup logic
console.log("Cleanup completed");
}
wrangler.jsonc configuration:
{
"triggers": {
"crons": ["0 */6 * * *"] // Every 6 hours
}
}
Queue Consumer Handler
export default {
async queue(batch: MessageBatch<QueueMessage>, env: Env, ctx: ExecutionContext): Promise<void> {
for (const message of batch.messages) {
try {
await processMessage(message.body, env);
message.ack();
} catch (error) {
console.error("Message processing failed:", error);
message.retry();
}
}
}
};
type QueueMessage = {
id: string;
data: unknown;
};
async function processMessage(body: QueueMessage, env: Env): Promise<void> {
// Process queue message
console.log("Processing message:", body.id);
}
Auto-Generate Environment Types
RECOMMENDED: Use wrangler types to automatically generate your Env interface from your wrangler.jsonc:
# Generate types from wrangler.jsonc
npx wrangler types
# Output to custom path
npx wrangler types ./types/env.d.ts
# Include runtime types (Wrangler >= 3.66.0)
npx wrangler types --experimental-include-runtime
This generates a worker-configuration.d.ts file with:
- Env interface matching all your bindings (KV, R2, D1, secrets, etc.)
- Runtime types matching your
compatibility_dateandcompatibility_flags - Service binding types with full RPC method signatures
Add to tsconfig.json:
{
"compilerOptions": {
"types": ["@cloudflare/workers-types", "./worker-configuration"]
}
}
When to regenerate:
- After adding/removing bindings in wrangler.jsonc
- After changing compatibility_date or compatibility_flags
- After modifying .dev.vars (secrets)
- Before deploying (run in CI/CD)
Example generated Env interface:
// worker-configuration.d.ts (auto-generated)
interface Env {
// From wrangler.jsonc bindings
MY_KV: KVNamespace;
MY_BUCKET: R2Bucket;
DB: D1Database;
COUNTER: DurableObjectNamespace;
AUTH_SERVICE: Service<typeof AuthService>;
AI: Ai;
MY_QUEUE: Queue;
// From .dev.vars (secrets)
DATABASE_URL: string;
API_KEY: string;
// From wrangler.jsonc vars
ENVIRONMENT: "development" | "staging" | "production";
API_VERSION: string;
}
Secrets Management
CRITICAL: Never put secrets in wrangler.jsonc! Secrets must be encrypted and hidden.
Secrets vs Environment Variables
| Type | Storage | Use For | Visibility |
|---|---|---|---|
| vars (wrangler.jsonc) | Plaintext | Non-sensitive config (URLs, flags) | ✅ Visible |
| secrets | Encrypted | API keys, passwords, tokens | ❌ Hidden |
Local Development with .dev.vars
Create a .dev.vars file for local secrets (NEVER commit this file):
# .dev.vars (add to .gitignore)
DATABASE_URL="postgresql://localhost:5432/dev"
API_KEY="dev-key-12345"
STRIPE_SECRET="sk_test_..."
CI/CD Best Practice: Empty .dev.vars
For CI/CD and type generation, commit a .dev.vars with empty values:
# .dev.vars (committed to git)
# Real values set via: wrangler secret put
DATABASE_URL=""
API_KEY=""
STRIPE_SECRET=""
Why this works:
wrangler typesreads.dev.varsto generateEnvtypes- Empty values create correct TypeScript types
- CI/CD can run type checking without real secrets
- Production secrets are set via
wrangler secret putor dashboard
Setting Production Secrets
Via Wrangler:
# Add/update secret (deploys immediately)
npx wrangler secret put API_KEY
# You'll be prompted for value
# List secrets (values never shown)
npx wrangler secret list
# Delete secret
npx wrangler secret delete API_KEY
Via Dashboard: Workers & Pages → Your Worker → Settings → Variables and Secrets → Add → Secret
Accessing secrets in code:
interface Env {
DATABASE_URL: string;
API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Access secrets from env (same as regular env vars)
const db = new Database(env.DATABASE_URL);
// Validate API key
const key = request.headers.get("x-api-key");
if (key !== env.API_KEY) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json({ success: true });
}
};
Secret Store (Account-Level Secrets)
For secrets shared across multiple Workers:
{
"secrets_store_secrets": [
{
"binding": "SHARED_API_KEY",
"store_id": "abc123def456",
"secret_name": "GLOBAL_API_KEY"
}
]
}
Accessing Secret Store:
interface Env {
SHARED_API_KEY: {
get(): Promise<string>;
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Secret Store requires .get() call
const apiKey = await env.SHARED_API_KEY.get();
return Response.json({ success: true });
}
};
See references/secrets.md for complete secrets management guide.
wrangler.jsonc Configuration
Complete example with common bindings:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-03-07",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"vars": {
"ENVIRONMENT": "production"
},
"kv_namespaces": [
{ "binding": "MY_KV", "id": "your-kv-id" }
],
"r2_buckets": [
{ "binding": "MY_BUCKET", "bucket_name": "my-bucket" }
],
"d1_databases": [
{ "binding": "DB", "database_name": "my-db", "database_id": "your-db-id" }
],
"durable_objects": {
"bindings": [
{ "name": "COUNTER", "class_name": "Counter" }
]
},
"queues": {
"producers": [
{ "binding": "MY_QUEUE", "queue": "my-queue" }
]
}
}
Key Configuration Rules:
- Use
wrangler.jsonc, NOTwrangler.toml - Set
compatibility_dateto current date (format:YYYY-MM-DD) - Always include
compatibility_flags: ["nodejs_compat"] - Enable observability with
head_sampling_rate: 1for full logging - Only include bindings that are actually used in your code
- Never include npm dependencies in wrangler.jsonc
See references/configuration.md for complete configuration options.
Background Tasks with waitUntil
Offload non-critical work to run after the response is sent:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Return fast response
const response = Response.json({ status: "accepted" });
// Process in background (doesn't block response)
ctx.waitUntil(performAsyncWork(request, env));
return response;
}
};
async function performAsyncWork(request: Request, env: Env): Promise<void> {
// This runs after the response is sent
const data = await request.json();
await env.MY_KV.put("processed", JSON.stringify(data));
}
Use waitUntil for:
- Analytics tracking
- Cache warming
- Logging
- Non-critical database writes
- Cleanup operations
Error Handling
HTTP Status Codes
async function handleRequest(request: Request, env: Env): Promise<Response> {
try {
// 400 - Bad Request
if (!request.headers.get("content-type")) {
return Response.json({ error: "Content-Type required" }, { status: 400 });
}
// 401 - Unauthorized
const apiKey = request.headers.get("x-api-key");
if (apiKey !== env.API_KEY) {
return Response.json({ error: "Invalid API key" }, { status: 401 });
}
// 404 - Not Found
const resource = await env.MY_KV.get("resource");
if (!resource) {
return Response.json({ error: "Resource not found" }, { status: 404 });
}
// 200 - Success
return Response.json({ success: true, data: resource });
} catch (error) {
// 500 - Internal Server Error
console.error("Request failed:", error);
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
Error Boundaries
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
return await handleRequest(request, env, ctx);
} catch (error) {
console.error("Unhandled error:", error);
return Response.json(
{
error: "An unexpected error occurred",
message: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}
};
Service Bindings (Microservices)
Service Bindings are the recommended way to build multi-Worker architectures. They enable Worker-to-Worker communication with zero latency, no HTTP overhead, and no additional costs.
Why Service Bindings?
| Benefit | Description |
|---|---|
| Zero latency | Both Workers run on same thread/server by default |
| No HTTP overhead | Direct RPC calls, not HTTP requests |
| Zero additional cost | Split functionality without increasing bills |
| Type-safe RPC | Call methods with full TypeScript support |
| Internal-only Workers | Build services not exposed to public internet |
| Independent deployment | Each Worker deploys on its own schedule |
RPC Interface (Recommended)
Export an RPC class from your service Worker:
// auth-service/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
export class AuthService extends WorkerEntrypoint<Env> {
async validateToken(token: string): Promise<{ valid: boolean; userId?: string }> {
const userId = await this.env.AUTH_TOKENS.get(token);
return { valid: !!userId, userId: userId || undefined };
}
async createToken(userId: string): Promise<string> {
const token = crypto.randomUUID();
await this.env.AUTH_TOKENS.put(token, userId, { expirationTtl: 86400 });
return token;
}
}
// Must also export default handler for HTTP access (if needed)
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return new Response("Auth Service - use RPC interface");
}
} satisfies ExportedHandler<Env>;
auth-service/wrangler.jsonc:
{
"name": "auth-service",
"main": "src/index.ts",
"compatibility_date": "2025-03-07",
"kv_namespaces": [
{ "binding": "AUTH_TOKENS", "id": "..." }
]
}
Calling the service from another Worker:
// api-worker/src/index.ts
interface Env {
AUTH: Service<import("auth-service").AuthService>;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const token = request.headers.get("Authorization")?.slice(7);
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
// Call RPC method directly
const result = await env.AUTH.validateToken(token);
if (!result.valid) {
return new Response("Invalid token", { status: 401 });
}
return Response.json({ userId: result.userId });
}
};
api-worker/wrangler.jsonc:
{
"name": "api-worker",
"main": "src/index.ts",
"compatibility_date": "2025-03-07",
"services": [
{
"binding": "AUTH",
"service": "auth-service"
}
]
}
Generate types for Service Bindings:
# In api-worker directory
npx wrangler types
This auto-generates the Env interface with the correct Service<AuthService> type.
Fetch-Based Service Binding
For simpler use cases or when you don't need RPC:
// api-worker/src/index.ts
interface Env {
AUTH: Fetcher;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Forward request to auth service
const authResponse = await env.AUTH.fetch(new Request("https://internal/validate", {
method: "POST",
body: JSON.stringify({ token: "..." })
}));
const result = await authResponse.json();
return Response.json(result);
}
};
See references/service-bindings.md for advanced patterns including environment-specific bindings and testing.
Detailed References
- references/handlers.md - Complete handler API reference, context objects, advanced routing
- references/configuration.md - Full wrangler.jsonc options, environment-specific config
- references/service-bindings.md - Advanced Service Bindings patterns, testing, environment routing
- references/secrets.md - Complete secrets management guide, .dev.vars, Secret Store, CI/CD best practices
Best Practices
- Use TypeScript by default - Better type safety and IDE support
- Generate types with wrangler types - Auto-generate Env interface from config and .dev.vars
- NEVER put secrets in wrangler.jsonc - Use
wrangler secret putor .dev.vars for local dev - Use .dev.vars with empty values for CI - Enables type generation without exposing secrets
- Enable observability - Set
observability.enabled: truefor logging - Use Service Bindings for microservices - Zero-cost, type-safe Worker-to-Worker calls
- Validate all inputs - Never trust user data
- Handle errors gracefully - Use try-catch and return appropriate status codes
- Use waitUntil for background tasks - Don't block response on non-critical work
- Keep bundle size small - Minimize dependencies for faster cold starts
Common Patterns
Complete API Worker
interface Env {
MY_KV: KVNamespace;
API_KEY: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
// CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
});
}
// Route handling
const url = new URL(request.url);
if (url.pathname === "/api/data" && request.method === "GET") {
const data = await env.MY_KV.get("data");
return Response.json({ data: data || null });
}
if (url.pathname === "/api/data" && request.method === "POST") {
const body = await request.json();
await env.MY_KV.put("data", JSON.stringify(body));
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
} catch (error) {
console.error("Request failed:", error);
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
};
Middleware Pattern
type Middleware = (
request: Request,
env: Env,
ctx: ExecutionContext,
next: () => Promise<Response>
) => Promise<Response>;
const authMiddleware: Middleware = async (request, env, ctx, next) => {
const apiKey = request.headers.get("x-api-key");
if (apiKey !== env.API_KEY) {
return new Response("Unauthorized", { status: 401 });
}
return next();
};
const loggingMiddleware: Middleware = async (request, env, ctx, next) => {
console.log(`${request.method} ${request.url}`);
const response = await next();
console.log(`Response: ${response.status}`);
return response;
};
function compose(...middlewares: Middleware[]) {
return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
let index = 0;
const next = async (): Promise<Response> => {
if (index >= middlewares.length) {
return handleRequest(request, env);
}
const middleware = middlewares[index++];
return middleware(request, env, ctx, next);
};
return next();
};
}
export default {
fetch: compose(loggingMiddleware, authMiddleware)
};
Resources
More from null-shot/cloudflare-skills
cloudflare-opennext
Deploy Next.js to Cloudflare Workers with full App Router, Pages Router, ISR, and SSG support. Load when creating Next.js projects for Workers, migrating from Vercel/next-on-pages, configuring caching (R2/KV/D1), accessing Cloudflare bindings via getCloudflareContext, or fixing bundle size issues.
195browser-rendering
Headless Chrome automation for web scraping, screenshots, PDFs, and testing at the edge. Load when capturing page screenshots, generating PDFs, scraping dynamic content, extracting structured data, or automating browser interactions. Supports REST API, Puppeteer, Playwright, and Stagehand.
17r2-storage
S3-compatible object storage for files, images, and large data. Load when handling file uploads, storing images/videos/documents, generating presigned URLs, using multipart uploads for large files, migrating from S3, or serving static assets from buckets.
16queues
Asynchronous message queues for reliable background processing. Load when offloading background tasks, batch processing messages, implementing retry logic with dead letter queues, rate limiting upstream APIs, or decoupling producers from consumers.
10wrangler
Cloudflare Workers CLI for deploying, developing, and managing Workers, KV, R2, D1, Vectorize, Hyperdrive, Workers AI, Containers, Queues, Workflows, Pipelines, and Secrets Store. Load before running wrangler commands to ensure correct syntax and best practices.
7hyperdrive
Connection pooling and caching for PostgreSQL and MySQL databases. Load when connecting Workers to existing Postgres/MySQL, reducing connection overhead, using Drizzle/Prisma with external databases, or migrating traditional database apps to the edge.
6