The service passes its tests, wrangler dev serves it, and the first production request answers
ReferenceError: process is not defined. The fix goes into the Worker entry. The Node entry keeps
the old code, because it works, and three months later the two files disagree about routing, error
bodies, and which variable holds the region.
What you get
You will end up with one service module, one HTTP front in the fetch shape, and two entry files of under twenty lines each, all checked by the same request suite. This is for you if one service has to run on a laptop and on Workers.
Short answer
Write the service as a function from a message to a result, and the HTTP front once, in the fetch
shape a Worker exports. Give Node a twenty-line adapter from node:http to that same handler, so
the laptop and the edge differ by one entry file each. Then run one request suite against both,
and against a stand-in with no Node globals and a CPU budget, because tests on Node see neither.
You will need
Node 22 or later, a message-based service you can call in process, and a request-level test suite
for it. The sample installs Hono and its Node
adapter for one row of the comparison and nothing else. Production is workerd, which is also what
wrangler dev runs, and it is not
Node: the Node.js compatibility
page lists which modules are polyfilled and from which compatibility date.
Voxgig maintains Seneca. This page compares its gateway plugins with the Hono runtime adapters and with keeping two entry files.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| @seneca/gateway with @seneca/gateway-express | The service is Seneca, so messages already have a JSON shape and an allow list | Two plugins whose contract you read from source, and no published Workers front, so the edge glue is still yours | The service is not Seneca, or the edge is the only front you run |
| Hono runtime adapters | You want routing, middleware and the adapters from one library, on Node, Workers, Deno and Bun | A dependency that owns the routing, and a Node adapter whose server you close yourself | One route and one message shape, where the library is more code than the front |
| Two separate entry files | The two fronts differ on purpose, such as auth at the edge and none on the laptop | Two copies of the routing and the error bodies, which drift unnoticed until a request finds the gap | The fronts are meant to be the same, which is the case this page is about |
The choice is between one abstraction and two entry points. A front in the fetch shape, whether
hand-written or from Hono, is one thing to maintain and one place a runtime difference can hide. Two
entry files are each simpler to read and are never checked against each other. The Seneca plugins
move the same decision into which plugin use loads at boot, at the price of a contract you read
from the plugin source.
Write the front once, in the fetch shape
A Worker’s entry is a function from a
Request to a Response.
Write the front in that shape and nothing in it belongs to either runtime.
export function createFront(service) {
return async function handle(request) {
const url = new URL(request.url)
const route = /^\/api\/([a-z]+)\/([a-z]+)$/.exec(url.pathname)
if (!route) return json({ ok: false, error: 'not_found' }, 404)
if (request.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
let body
try {
body = await request.json()
} catch {
return json({ ok: false, error: 'bad_json' }, 400)
}
let result
try {
result = await service.act({ ...body, name: route[1], verb: route[2] })
} catch (err) {
return json({ ok: false, error: 'exception', detail: `${err.name}: ${err.message}` }, 500)
}
return json(result, result.ok ? 200 : 422)
}
}
The front owns four decisions: which paths map to which message, what a malformed request gets
back, which status a failed message becomes, and what a service that throws turns into. Those are
the decisions that drift when two files each make them. The service behind act takes its configuration as an argument, because the edge
has no process.env to read it from.
Give Node the same handler
Node speaks node:http, so the laptop gets an adapter. This is the whole of what an adapter
package does.
export const toNodeListener = (handle) => async (req, res) => {
try {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const request = new Request(`http://${req.headers.host ?? 'localhost'}${req.url}`, {
method: req.method,
headers: req.headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
})
const response = await handle(request)
const body = Buffer.from(await response.arrayBuffer())
// A plain object keeps one value per name, and there can be several Set-Cookie.
const headers = { ...Object.fromEntries(response.headers), 'set-cookie': response.headers.getSetCookie() }
res.writeHead(response.status, headers)
res.end(body)
} catch {
// An unhandled rejection in a listener exits the process, and the socket gets nothing.
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' })
res.end('{"ok":false,"error":"exception"}')
}
}
Two lines are there because node:http forgives less than a fetch handler does. A throw inside
a listener is an unhandled rejection, and Node’s
default for one is to exit the
process, so the client gets nothing; the catch answers 500 instead. And a plain object keeps
one value per name where a Headers object holds several Set-Cookie, so those come through
getSetCookie.
The two entries then differ in one line each: where the configuration comes from.
export function createNodeServer(env = process.env) {
const handle = createFront(createService({ region: env.REGION ?? 'local' }))
return createServer(toNodeListener(handle))
}
export default {
fetch(request, env = {}) {
handle ??= createFront(createService({ region: env.REGION ?? 'edge' }))
return handle(request)
},
}
Hono makes the same move with a library. app.fetch is the fetch-shaped handler, a Worker
exports it directly, and
serve({ fetch: app.fetch }) from @hono/node-server is the adapter here with WebSocket and
static file support attached. The cost is that Hono owns the routing, and on Node the returned
server is yours to close.
The Seneca route makes the decision at boot instead. @seneca/gateway
exports a handler that takes a JSON message, checks it against an allow list of patterns, and calls
act. @seneca/gateway-express mounts that
handler as an Express route built from the request body, parameters, and query. Loading a different
gateway plugin is the swap. The registry lists Express and Lambda fronts and no Workers front, so at
the edge the glue between the gateway handler and fetch is yours to write.
Run one suite against every front
Nine requests, in suite.mjs, each with the status it expects and a check on the body. The only
thing that changes between rows is how the request is delivered: over a socket to a listening
server, or as a direct call to a fetch handler.
node --experimental-vm-modules --expose-gc --no-warnings demo.mjs
the same nine requests against every front
node:http around the fetch front 9 passed 0 failed
the fetch front called directly 9 passed 0 failed
Hono under @hono/node-server 9 passed 0 failed
Hono as a fetch handler 9 passed 0 failed
the fetch front under the stand-in 9 passed 0 failed
Hono under the stand-in 9 passed 0 failed
the first-attempt service, which passes every unit test on Node
the fetch front on Node 9 passed 0 failed
the fetch front under the stand-in 6 passed 3 failed
create an order 500 {"ok":false,"error":"exception","detail":"ReferenceError: process is not defined"}
reject a bad order 500 {"ok":false,"error":"exception","detail":"ReferenceError: process is not defined"}
order report 500 {"ok":false,"error":"exceeded_cpu_time","budget_ms":10}
The first block is the property the page promises: the same nine answers from six fronts. The
second is the reason the suite has to run somewhere other than Node. pitfall.mjs is the service
as it is usually written first: it reads the region from process.env, mints an id with Buffer,
and sorts a 200,000-row ledger to answer a report. All nine requests pass on Node. Three fail under
a runtime with no process, no Buffer, and ten milliseconds of CPU per request.
Catch what Node cannot see
wrangler dev runs workerd, so a missing global fails there. It does not fail in the unit tests,
which run on Node. The CPU limit fails nowhere on a laptop: the Wrangler configuration reference
says limits are
only enforced when deployed,
not in local development. The stand-in in edge-standin.mjs reproduces both differences inside the
test process, using a Node vm context.
const WORKER_GLOBALS = [
'Request', 'Response', 'Headers', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder',
'crypto', 'console', 'setTimeout', 'clearTimeout', 'queueMicrotask', 'structuredClone',
'atob', 'btoa', 'fetch', 'AbortController', 'AbortSignal', 'Blob', 'FormData',
'ReadableStream', 'WritableStream', 'TransformStream',
]
The module graph is evaluated in a context that has those globals and no others, so process
and Buffer are reference errors, as they are under workerd without the compatibility flag.1
The budget then charges each request the time the event loop was busy, read from
eventLoopUtilization, so waiting on a timer or a
socket costs nothing, which is the platform’s own rule.
export const withBudget = (fetch, budgetMs) => async (request, env = {}) => {
globalThis.gc?.()
await turn()
const before = performance.eventLoopUtilization()
try {
const response = await fetch(request, env)
await turn()
const busyMs = performance.eventLoopUtilization(before).active
return busyMs > budgetMs ? failure({ error: 'exceeded_cpu_time', budget_ms: budgetMs }) : response
} catch (err) {
return failure({ error: 'exception', detail: `${err.name}: ${err.message}` })
}
}
The ten milliseconds is the free plan’s figure on the limits page, which also says the average Worker uses about 2.2 ms.2 The collection before each measurement is there because a test process is not an isolate. Without it, a cheap handler is charged for the garbage of the report before it, and the suite fails one run in two.
The real runtime agrees with the stand-in on the globals. Under Miniflare, with no compatibility
flags, process.env.HOME answered ReferenceError: process is not defined and Buffer.from
answered ReferenceError: Buffer is not defined; with nodejs_compat both existed. A handler that
spun for 200 ms returned 200, because the limit is not enforced locally, which is the gap the
budget exists to close.3
Check it worked
The suite is the test. Nine cases pin the properties the page claims.
node --experimental-vm-modules --expose-gc --no-warnings --test front.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 884.996941
The first test asserts that the suite passes through node:http and through the fetch front
alike, with the statuses in the same order. The adapter added nothing and lost nothing. The fourth
is the one to keep: the first-attempt service passes on Node and fails under the stand-in on the
request that reads process.env. get a product still passes there, because the failure is per
request, not per deploy. The sixth proves the budget charges CPU and not waiting, with a handler
that sleeps for 50 ms and passes a 10 ms budget. The last two are the adapter’s own: a service
that throws is a 500 from the front and over the socket, with the process still up, and both
Set-Cookie values come back.
When it goes wrong
The first production request answers process is not defined. The service reads its
configuration from the Node environment. Pass it in from env, which is where a Worker
receives its bindings,
or set a compatibility date that turns Node compatibility on, and add the case to the suite either
way.
The edge answers error 1102. The request ran over its CPU budget, which the
errors reference lists as
Worker exceeded CPU time limit. Move the work out of the request: keep the report up to date on
each write, as service.mjs does, or hand it to a queue.
The suite passes as a direct call and fails over the socket. The adapter dropped something the handler needed, usually the body on a method it assumed had none, or a header it did not copy back. Compare the two results status by status, which is what the first test does.
When not to do this
Do not keep the stand-in as proof of production behavior. It is a Node context with the Node
globals removed, and workerd differs from it in ways a global list cannot express: fetch
semantics, streaming, and every binding. Run wrangler dev before a deploy and a canary after it.
The stand-in is for the two differences the local runtime hides.
Do not force one front where the fronts differ on purpose. A laptop with no auth and an edge with a signed cookie are two fronts, and pretending otherwise puts the difference behind a flag that every reader has to trace.
Do not adopt Seneca’s gateway plugins to get this pattern. They assume a Seneca service, they publish Express and Lambda fronts and no Workers one, and their contract lives in the source. For a service that already speaks Seneca messages they are the natural front. For any other service they are a second framework.
Do not take on Hono for a single route. The adapter on this page is twenty lines, and a library that owns the routing is the right trade only when you want its middleware too.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, hono 4.13.9 and @hono/node-server 2.1.1. Both output
blocks are what the preceding command printed. The workerd behavior quoted in prose was measured
once under Miniflare 4.20260529.0, which bundles workerd 1.20260529.1, at compatibility date
2026-06-01, and is recorded rather than re-run. The gated commands use the stand-in in
edge-standin.mjs, which is a Node vm context and not workerd.
Footnotes
-
Whether
processexists in a Worker depends on a date in a configuration file. For compatibility dates from 2024-09-23 thenodejs_compatflag turns it on, and from 2026-08-04 it is on unless turned off, which takes two flags,no_nodejs_compatandno_nodejs_compat_v2. Theprocess.envit provides is an empty object, since there is no process, until a third flag fills it from the bindings. A global that is present or absent by calendar is a portability problem of a new kind. The page that describes it takes care to say that existing projects need not remove the flags they no longer need. ↩︎ Back to text -
The limits page gives the free plan 10 ms of CPU per request and the paid plan 30 seconds by default, which can be raised to five minutes. It adds that the average Worker uses about 2.2 ms. That is a factor of three thousand between the two plans, for a workload the same page says fits in two. ↩︎ Back to text
-
Cloudflare’s local development page calls Miniflare a simulator and, in the same sentence, says it executes your Worker code using the same runtime used in production. Both are true. The runtime is real and the surroundings are simulated: bindings are local, and limits are enforced only on the network. A simulator of everything except the part that runs your code describes most local development, and is an unusually exact way to put it. ↩︎ Back to text