Skip to Content
HostingRuntime Contract

Runtime Contract

A deployed project runs on Cloudflare’s Worker runtime (workerd), not Node.js. Most code ports without changes, but four differences catch people the first time they deploy a server-rendered app (Next.js/OpenNext, RSC, or any framework with a fetch handler). Knowing them up front turns a day of redeploys into minutes.

1. Configuration lives on env, not process.env

Your Worker receives its bindings — env vars, secrets, D1, KV, R2, and the Inbox — as the second argument to fetch, not on a global process.env:

export default { async fetch(request, env, ctx) { const base = env.API_BASE; //

There is no runtime process.env on workerd. If a library reads process.env.SOMETHING, that value is undefined unless it was replaced at build time (see the next section). Thread bindings down from the env argument instead.

2. NODE_ENV is pinned to production for you, except in prebuilt bundles

Many libraries (React most notably) branch on process.env.NODE_ENV and ship a slower, larger development build when it isn’t "production". Because workerd has no runtime process.env, an unpinned NODE_ENV silently evaluates falsy and you get the dev build — the site renders, but client components can fail to hydrate.

yolo deploy handles this for you: when it bundles your Worker with esbuild, it statically replaces process.env.NODE_ENV with "production". You don’t need to do anything.

The one exception is a prebuilt worker ("worker": { "prebuilt": true } in .yolo/deploy.json) — the output of a framework build like OpenNext, which ships as-is without re-bundling. There, yolo deploy can’t rewrite the code, so your framework build must pin NODE_ENV itself. If it doesn’t, yolo deploy warns:

deploy: warn prebuilt worker references process.env.NODE_ENV — workerd has no runtime process.env, so an unpinned NODE_ENV can ship a dev build …

Fix it in your build step, for example with an esbuild define:

esbuild.build({ /* … */ define: { 'process.env.NODE_ENV': '"production"' } });

3. node: built-ins need the nodejs_compat flag

workerd provides a subset of Node’s built-in modules (node:crypto, node:async_hooks, node:stream, node:buffer, and more) — but only when the nodejs_compat compatibility flag is enabled. Add it to .yolo/deploy.json:

{ "compatibilityFlags": ["nodejs_compat"] }

yolo deploy doesn’t fail the build on a node: import — it leaves the module for the runtime to provide and warns if the flag is missing:

deploy: warn worker imports node: builtins (node:async_hooks) but compatibilityFlags does not include "nodejs_compat" …

A misspelled builtin (node:async_hook) is still a hard build error — it isn’t a real module. And a require("node:…") of a builtin gets a build warning: a CommonJS require of a builtin compiles to a helper that throws in an ESM Worker unless it’s guarded by try/catch (a common optional-dependency probe), so the build proceeds but an unguarded call fails at runtime. Prefer an ESM import.

nodejs_compat is the only compatibility flag hosting accepts — a ship with any other flag is rejected. Enabling it turns on the supported node: built-ins as a group; you don’t opt into modules individually.

4. Errors surface as a branded page, and logs are opt-in

An uncaught exception in your fetch handler does not show the visitor a raw Cloudflare 1101 — the router catches it and renders a branded Application error page. That keeps users out of a broken state, but it also means the stack trace isn’t in the response.

To see what actually threw, read the release’s logs:

deploy.logs {projectId, kind: "app"} # console output + uncaught exceptions deploy.logs {projectId, kind: "requests"} # per-request status / latency / error class

App logs are captured for every release shipped while log capture is on (it is on for YOLO Host). A release shipped before capture was turned on has nothing to query — re-ship and reproduce. Request-level outcomes (kind: "requests") are always available.

A note on newer JS globals

workerd tracks a fixed compatibility date, so a handful of newer JavaScript globals are gated on it. If your app (or a dependency) relies on a global that isn’t present at runtime — you’ll see a ReferenceError in deploy.logs — the cause is usually the compatibility date rather than a bug in your code. Prefer widely-supported APIs, or vendor a small polyfill into your bundle.

Check before you ship

yolo deploy doctor lints your worker entry against this contract offline — no bundling, no network:

$ yolo deploy doctor yolo deploy doctor — worker project ✓ entry 'src/index.ts' resolves

It scans your entry file only (warnings are advisory — they don’t block). For a full dependency-level check, yolo deploy --dry-run bundles without shipping.

For the highest-fidelity check, run your project on the actual runtime before shipping:

yolo deploy dev [--port 8787] [--var KEY=VALUE ...]

yolo deploy dev serves your project locally on workerd (the same runtime and pinned compatibility date as production), so every gotcha on this page — an empty process.env, a missing node: builtin, a WeakRef/FinalizationRegistry gap, a dev-React build — surfaces locally instead of only after a deploy. A plain node dist/server.js runs on Node and gives false confidence; yolo deploy dev doesn’t. Real env vars and secrets are server-side, so pass local values with --var; declared KV/D1/R2 bindings get empty in-memory stand-ins.

Checklist

Before shipping a server-rendered app:

  • Run yolo deploy doctor and clear the warnings.
  • Read config from the env fetch argument, never process.env.
  • Add "compatibilityFlags": ["nodejs_compat"] if you (or your deps) import node: builtins.
  • For a prebuilt bundle, pin NODE_ENV=production in your framework build.
  • Ship a preview, hit it in a browser, and check deploy.logs before promoting to production.
Last updated on