Abik Maharjan
Blog

Cloudflare Workers, Properly

Part 1 of The Cloudflare Starter Series. Bindings, routing, logging, and the one bug everybody ships.

11 min read

Most “hello world on Workers” tutorials leave you with a function that returns a string and no idea what to do next. This one goes the other way: it’s the set of things I wish someone had told me before I put a Worker in front of real traffic and why the config file matters more than the code, why your background promise silently never ran, and why module-scope variables are a race condition waiting to happen.

We’ll build against one running example throughout the series: Postly, a scheduled-posting API. A user POSTs a message with an optional publish_at, and it goes out then, possibly days later, via a slow, flaky third-party API.

The concept

A Worker is a JavaScript function that runs in one of Cloudflare’s ~300 data centres, starting in about a millisecond, close to whoever called it.

The important part isn’t “it’s fast.” The important part is what it isn’t:

  • It is not a Node.js process. Nothing you write to disk survives the request, there’s no listening socket, and there’s no machine environment behind process.env,under nodejs_compat it's populated from your own bindings, not from a host.
  • It is not long-lived. It exists for the duration of a request and is then thrown away.
  • It has no ambient access to anything. Not your database, not your secrets, not another Worker. Everything it can touch must be handed to it explicitly.

That last one is the whole mental model. On a normal server you write new Client(process.env.DATABASE_URL) and the server can reach the database because it's on a network that can reach the database. A Worker has no such network. Instead it gets bindings: named handles, declared in config, injected into your function at runtime.

If it’s not in wrangler.jsonc (or wrangler.toml), it does not exist. This feels annoying for about a day and then becomes the thing you miss everywhere else, because your config file is a complete, honest inventory of everything your service depends on.


The shape of a Worker

// src/index.js
export default {
  async fetch(request, env, ctx) {
    return new Response('hello');
  },
};

Three arguments, and you will use all three:

  • request —  a standard Request. Web APIs, not Express. request.method, new URL(request.url), await request.json().
  • env —  your bindings. This is not a global. It arrives per-invocation, which is why you pass it down through your call stack instead of importing a singleton.
  • ctx —  execution context. Mainly ctx.waitUntil(promise), which we'll get to.

fetch is only one entry point. The same default export can also handle:

export default {
  async fetch(request, env, ctx) { /* HTTP */ },
  async queue(batch, env, ctx) { /* Queue messages */ },
  async scheduled(event, env, ctx) { /* Cron trigger */ },
};

A Worker with only a queue handler is a perfectly normal thing to deploy. It has no URL and nobody can call it over HTTP  -  it just eats messages. We'll build one later in this post.


Routing without a framework

You’ll see a lot of tutorials reach for Hono or itty-router. They’re good. You also don’t need them, and writing the router yourself once is the fastest way to understand that a Worker is just a function that returns a Response.

Here’s a route table with :param support in about thirty lines:

// src/router.js
// Each route is method + a path template + a handler.
// Templates are matched segment by segment; `:name` captures.
const ROUTES = [
  { method: 'POST',   pattern: '/v1/posts',         handler: createPost },
  { method: 'GET',    pattern: '/v1/posts',         handler: listPosts },
  { method: 'GET',    pattern: '/v1/posts/:postId', handler: getPost },
  { method: 'DELETE', pattern: '/v1/posts/:postId', handler: deletePost },
];
 
function matchRoute(method, pathname) {
  const segments = pathname.split('/').filter(Boolean);
  for (const route of ROUTES) {
    if (route.method !== method) continue;
    const template = route.pattern.split('/').filter(Boolean);
    if (template.length !== segments.length) continue;
    const params = {};
    let matched = true;
    for (let i = 0; i < template.length; i++) {
      if (template[i].startsWith(':')) {
        params[template[i].slice(1)] = decodeURIComponent(segments[i]);
      } else if (template[i] !== segments[i]) {
        matched = false;
        break;
      }
    }
    if (matched) return { handler: route.handler, params };
  }
  return null;
}
export { matchRoute };

Segment-count matching first means the loop bails early on almost every route, and there’s no regex to get subtly wrong. When you outgrow it -wildcards, method-not-allowed distinctions, middleware chains, then swap in a router library. You’ll know when.


One response shape, defined once

Pick an envelope on day one and never deviate. Every response your service produces, success or failure, has the same top-level shape:

// src/http.js
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' };
export function ok(data, status = 200) {
  return new Response(
    JSON.stringify({ success: true, data, error: null }),
    { status, headers: JSON_HEADERS },
  );
}
export function fail(code, message, status = 500) {
  return new Response(
    JSON.stringify({ success: false, data: null, error: { code, message } }),
    { status, headers: JSON_HEADERS },
  );
}

Why bother? Because the client can write one parser. if (!body.success) showError(body.error.message) works for a validation failure, a 404, an upstream timeout, and an unhandled exception. The alternative ,  sometimes {error: "..."}, sometimes a bare string, sometimes an empty body with a status code ,  means every caller writes defensive spaghetti forever.

Pair it with a typed error so handlers can throw instead of threading status codes back through five layers:

// src/api-error.js
export class ApiError extends Error {
  constructor(code, message, status) {
    super(message);
    this.code = code;       // machine-readable: 'VALIDATION_ERROR'
    this.status = status;   // HTTP status: 400
  }
}
 
export const badRequest = (msg) => new ApiError('VALIDATION_ERROR', msg, 400);
export const notFound   = (msg) => new ApiError('NOT_FOUND', msg, 404);
export const forbidden  = (msg) => new ApiError('FORBIDDEN', msg, 403);

Putting the entry point together

// src/index.js
import { matchRoute } from './router.js';
import { ok, fail } from './http.js';
import { ApiError } from './api-error.js';
import { authenticate } from './auth.js';
 
async function handleFetch(request, env, ctx) {
  const { pathname } = new URL(request.url);
  // Health check is public and comes before anything that can fail.
  if (request.method === 'GET' && pathname === '/v1/health') {
    return ok({ status: 'ok', service: 'postly' });
  }
  const match = matchRoute(request.method, pathname);
  if (!match) return fail('NOT_FOUND', 'Route not found', 404);
  try {
    // Auth runs once, here - never inside individual handlers, where it can
    // be forgotten on the one route that mattered.
    const user = await authenticate(request, env);
    return await match.handler({
      request, env, ctx, params: match.params, user,
    });
  } catch (err) {
    if (err instanceof ApiError) {
      return fail(err.code, err.message, err.status);
    }
    // Unexpected: log the real thing, return something safe.
    console.log(JSON.stringify({
      level: 'ERROR',
      service: 'postly',
      message: 'Unhandled exception',
      path: pathname,
      error: err.message,
    }));
    return fail('INTERNAL_ERROR', 'Something went wrong', 500);
  }
}
export default { fetch: handleFetch };

Two structural decisions worth copying.

Auth is in the router, not the handler. A handler that has to remember to call authenticate() is a handler that will eventually forget. Put the gate before the dispatch and the only way to add an unauthenticated route is to do it deliberately, above the route table, where a reviewer will see it.

One try for the whole request. Handlers throw. The router translates. Every handler gets to read like the happy path.


console.log is your only debugger. So, log structurally

There’s no node --inspect attached to production. Your entire observability story is whatever you wrote to stdout, which wrangler tail streams and Workers Logs indexes.

That means logging a bare string is throwing away most of the value:

// Useless at 3am
console.log('failed to publish post');

Log JSON with a correlation ID instead, and you can filter to a single request across every Worker it touched:

// src/logger.js
export function createLogger(service, requestId) {
  const emit = (level, message, extra = {}) => {
    console.log(JSON.stringify({
      level,
      timestamp: new Date().toISOString(),
      service,
      requestId,
      message,
      ...extra,
    }));
  };
 
  return {
      info:  (msg, extra) => emit('INFO', msg, extra),
      warn:  (msg, extra) => emit('WARN', msg, extra),
      error: (msg, extra) => emit('ERROR', msg, extra),
    };
  }

Then in the router:

const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID();
const logger = createLogger('postly', requestId);

Pass logger down alongside env. When you later add a queue consumer and a Durable Object, propagate the same requestId into them — now one grep gives you the full trace across four services.

Never log the secret, the token, the params array, or the user’s content. Log has_token: !!token and param_count: params.length. Logs get shipped places you didn't plan for.

Turn on log persistence in config, or you’re logging to nowhere:

{
  "observability": {
    "enabled": true,
    "logs": { "enabled": true, "head_sampling_rate": 1 }
  }
}

ctx.waitUntil: the one that bites everyone

When you return a Response, the Worker's lifetime ends. Any promise still in flight is cancelled. Not "runs in the background"  but  cancelled, mid-flight, silently.

// BROKEN. The analytics call may never happen.
async function createPost({ request, env }) {
  const post = await savePost(env, await request.json());
  recordAnalytics(env, post);   // no await, no waitUntil → dead promise
  return ok(post, 201);
}

This is the single most common Workers bug, and it’s insidious because it works fine locally and under light load. The fix is to hand the promise to the runtime:

async function createPost({ request, env, ctx }) {
  const post = await savePost(env, await request.json());
  ctx.waitUntil(recordAnalytics(env, post));  // outlives the response
  return ok(post, 201);
}

waitUntil says: keep this invocation alive until this promise settles, but don't make the user wait for it. Use it for analytics, cache warming, audit logs — anything the caller doesn't need the result of.

The rule of thumb: **every promise in a Worker must be **await**ed or **waitUntiled. If you're deliberately fire-and-forgetting, write void somePromise() so the next reader knows it was a decision.


Global scope is a trap

// DON'T
let requestCount = 0;
let currentUser = null;
 
export default {
  async fetch(request, env) {
    requestCount++;                        // meaningless - resets constantly
    currentUser = await getUser(request);  // RACE: requests clobber each other
    return ok({ user: currentUser });
  },
};

A Worker instance handles many concurrent requests, and there are many instances worldwide, each created and destroyed unpredictably. Module-scope mutable state is simultaneously not shared (so it’s useless as a counter) and shared within an instance (so it’s a race condition). Worst of both.

Module scope is fine for genuinely constant things — a regex, a lookup table, a route array. Anything request-specific lives in the request.

And if you actually need “one place where state is consistent and requests don’t race” — that’s a Durable Object, and it’s the next post.


Secrets are not env vars

Three tiers, and mixing them up is a security incident.

Tier one  -  plaintext vars. Visible in the dashboard, committed to your repo.

{
  "vars": {
    "API_VERSION": "v1",
    "UPSTREAM_URL": "https://..."
  }
}

Tier two  - Worker secrets. Encrypted at rest, write-only, per-Worker, not in your repo.

npx wrangler secret put THIRD_PARTY_API_KEY

Tier three  -  account-level Secrets Store. One secret, shared by many Workers, rotated in one place.

{
  "secrets_store_secrets": [
    { "binding": "DB_AUTH", "store_id": "...", "secret_name": "SHARED_DB_AUTH" }
  ]
}

That third tier has a wrinkle worth knowing before it costs you an afternoon: a Secrets Store binding is an **object with an async **.get(), while a wrangler secret is a plain string. Local dev usually gives you the string; production gives you the object. Same code, two shapes.

Normalise it once:

// src/secrets.js
export async function resolveSecret(binding) {
  if (typeof binding === 'string') return binding;
  if (binding && typeof binding.get === 'function') return binding.get();
  throw new Error('Secret binding is missing or of an unexpected type');
}

Now await resolveSecret(env.DB_AUTH) works in both worlds and your code never has to know which one it's in.

For local development, put fake values in .dev.vars (and put .dev.vars in .gitignore):

THIRD_PARTY_API_KEY=fake-local-key
DB_AUTH=local-dev-secret

One gotcha: if a binding is declared in wrangler.jsonc, that declaration shadows .dev.vars. If you get Secret "X" not found locally while .dev.vars clearly has it, that's why — the declared binding won, and it has nothing behind it locally.


Service bindings: Workers calling Workers

Postly is going to grow. The publish logic wants to live in its own Worker — different deploy cadence, different failure domain, maybe a different team.

Naive approach: give it a URL and fetch() it. Now you've bought yourself DNS, TLS, public exposure, and a hand-rolled auth scheme to stop the internet from calling your internal service.

Service bindings skip all of that:

// postly's wrangler.jsonc
{
  "services": [
    { "binding": "PUBLISHER", "service": "postly-publisher" }
  ]
}
const res = await env.PUBLISHER.fetch(
  new Request('https://internal/publish', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ postId, requestId }),
  }),
);

What’s actually happening: no network. Cloudflare invokes the target Worker in the same data centre, in the same request context. Zero added latency, no TLS handshake, no DNS. The hostname in that URL (https://internal/) is ignored — it just has to be a valid URL for the Request constructor.

The security property is the real prize. Give postly-publisher no route and no workers.dev subdomain, and it becomes unreachable from the internet, permanently. Not "protected by a secret" — unaddressable. The binding is the only door.

Two things to watch:

  • Deploy order matters. A binding to a Worker that doesn’t exist yet fails at deploy time. New service first, then the Worker that binds it.
  • Still send a correlation ID. Pass requestId in the body or a header so your logs stitch together across the hop.

Queues: the cameo

Postly’s publish step calls a rate-limited third-party API that takes 2 seconds and fails 10% of the time. You do not want that inside the user’s request.

Producer side — declare the binding, then one line to enqueue:

{
  "queues": {
    "producers": [
      { "binding": "PUBLISH_QUEUE", "queue": "postly-publish-queue" }
    ]
  }
}
await env.PUBLISH_QUEUE.send({ postId, requestId, attempt: 1 });
return ok({ id: postId, status: 'queued' }, 202);

The user gets a 202 in 20ms. Consumer side is a separate Worker with a queue handler and no fetch route at all:

{
  "queues": {
    "consumers": [
      {
        "queue": "postly-publish-queue",
        "max_batch_size": 10,
        "max_retries": 3,
        "dead_letter_queue": "postly-publish-dlq"
      }
    ]
  }
}
// src/index.js of postly-publish-consumer
import { publish } from './publish.js';
import { createLogger } from './logger.js';
 
export default {
  async queue(batch, env) {
    for (const message of batch.messages) {
      const logger = createLogger('postly-publisher', message.body.requestId);
      try {
        await publish(message.body, env, logger);
        message.ack();
      } catch (err) {
        logger.error('Publish failed', {
          error: err.message,
          post_id: message.body.postId,
        });
        message.retry();   // back to the queue, then eventually the DLQ
      }
    }
  },
  // A queue-only Worker still needs a default export the runtime accepts.
  async fetch() {
    return new Response('{"success":false}', { status: 404 });
  },
};

The decision you must make explicitly: ack or retry?

Queues give you at-least-once delivery. A retried message will be delivered again, and under rare conditions a message can be delivered twice even without a retry. So:

  • If the work is idempotent (upserting a row, setting a status) — retry() freely. Retrying is strictly better than losing the message.
  • If the work is not idempotent (posting to Twitter, charging a card, sending an email) — a retry can double-fire. Either make it idempotent with a dedupe key, or ack() unconditionally and recover failures from your database instead (WHERE published_at IS NULL).

That second option feels wrong the first time you write it — a catch that logs and acks looks like swallowing an error. It isn't, as long as the database row is the source of truth and something sweeps it. Write the comment explaining that, right there in the catch, because the next person to read it will otherwise "fix" it into a duplicate-tweet generator.

Two more traps:

  • A producer with no consumer is silent data loss. The queue accepts everything and nothing ever reads it. Nothing errors. Before you ship a producer binding, confirm a consumer for that exact queue name exists in some deployed Worker.
  • A queue has exactly one consumer. Not “should have” — can have. You cannot point two Workers at the same queue to fan out, and you cannot run an old consumer alongside a new one during a migration. The restriction is deliberate: a single reader is what lets Queues promise at-least-once delivery without handing the same message to two places. If two things must happen per message, do both in the one consumer, or produce to two queues.

wrangler.jsonc, annotated

{
  "name": "postly",
  "main": "src/index.js",
  // Pins runtime behaviour. Set it to today's date when you start; bump it
  // deliberately, never automatically. Changing it can change semantics.
  "compatibility_date": "2026-08-01",
  // Enables Node built-ins (node:crypto, node:buffer, Buffer, etc.).
  // Needed by many npm packages, including the `pg` driver.
  "compatibility_flags": ["nodejs_compat"],
  "vars": { "API_VERSION": "v1" },
  "observability": {
    "enabled": true,
    "logs": { "enabled": true, "head_sampling_rate": 1 }
  },
  // Everything below is a binding. Nothing your code can reach
  // is absent from this list.
  "kv_namespaces": [
    { "binding": "RULES_KV", "id": "..." }
  ],
  "d1_databases": [
    { "binding": "DB", "database_name": "postly", "database_id": "..." }
  ],
  "durable_objects": {
    "bindings": [
      { "name": "SCHEDULER", "class_name": "PostScheduler" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["PostScheduler"] }
  ],
  "queues": {
    "producers": [
      { "binding": "PUBLISH_QUEUE", "queue": "postly-publish-queue" }
    ]
  },
  "services": [
    { "binding": "PUBLISHER", "service": "postly-publisher" }
  ]
}

compatibility_date deserves a sentence of its own. It is not a version number you keep current for hygiene. It's a snapshot of runtime semantics  - bumping it can change how existing code behaves. Set it once, and bump it only when you want a specific new behaviour, after reading what changed.


When a Worker is the wrong tool

Honesty is cheaper than a rewrite.

  • CPU-bound work. Workers are metered on CPU time, with a ceiling. Video transcoding, large ML inference, heavy image processing — wrong shape. Containers or Workers AI exist for exactly this.
  • Code that needs a persistent filesystem or a raw TCP listener. nodejs_compat gets you node:fs and node:net, but the filesystem is virtual and net only dials out. It is not Node.
  • Very large in-memory working sets. You get roughly 128MB, and it’s not yours to keep.
  • A monolith you want to lift and shift unchanged. You can run Express on Workers. Whether you should is a different question — you’ll be paying framework overhead per request for an abstraction designed around a long-lived process.

For the other 90% of backend work  such as APIs, webhooks, auth flows, orchestration, glue, proxies, scheduled jobs ,  a Worker is very hard to beat.


Recap

  • A Worker is a function. fetch, queue, scheduled are its entry points.
  • Bindings are the platform. If it’s not in wrangler.jsonc, your code can't reach it — and your config is therefore a complete dependency inventory.
  • Route with a table, respond with one envelope, throw typed errors and catch once.
  • Log structured JSON with a requestId. It's all the debugging you get.
  • Every promise gets awaited or waitUntiled. No exceptions.
  • No mutable module-scope state. Ever.
  • Service bindings for internal calls: faster, and the target can have no public address at all.
  • Queues for slow or flaky work — and decide ack-vs-retry based on whether the work is idempotent.

Next up: what happens when you need something a Worker fundamentally cannot do .. remember things, serialise concurrent access, and wake up two days from now. (If there is no link here to the new blog, its probably in my drafts, haha)

12 views