How-to › Run message-based services

How to run one service locally and at the edge without forking it#

Write the HTTP front once in the fetch shape, give Node a twenty-line adapter, and run one request suite against both fronts and a stand-in that has no Node globals.

Audience
Platform team
Level
advanced
Topic
Expose services over HTTP and at the edge
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
@seneca/gateway with @seneca/gateway-expressThe service is Seneca, so messages already have a JSON shape and an allow listTwo plugins whose contract you read from source, and no published Workers front, so the edge glue is still yoursThe service is not Seneca, or the edge is the only front you run
Hono runtime adaptersYou want routing, middleware and the adapters from one library, on Node, Workers, Deno and BunA dependency that owns the routing, and a Node adapter whose server you close yourselfOne route and one message shape, where the library is more code than the front
Two separate entry filesThe two fronts differ on purpose, such as auth at the edge and none on the laptopTwo copies of the routing and the error bodies, which drift unnoticed until a request finds the gapThe 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.

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

  1. Whether process exists in a Worker depends on a date in a configuration file. For compatibility dates from 2024-09-23 the nodejs_compat flag turns it on, and from 2026-08-04 it is on unless turned off, which takes two flags, no_nodejs_compat and no_nodejs_compat_v2. The process.env it 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

  2. 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.