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

Source: https://voxgig.com/howto/run-one-service-locally-and-at-the-edge

- Audience: platform-team
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://hono.dev/docs/getting-started/nodejs) and its Node
adapter for one row of the comparison and nothing else. Production is workerd, which is also what
[`wrangler dev` runs](https://developers.cloudflare.com/workers/local-development/), and it is not
Node: the [Node.js compatibility](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)
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](https://github.com/senecajs/seneca-gateway) | 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](https://hono.dev/docs/getting-started/cloudflare-workers) | 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](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/) | 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`](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/).
Write the front in that shape and nothing in it belongs to either runtime.

```js title="front.mjs"
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.

```js title="node-adapter.mjs"
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](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode) 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`](https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie).

The two entries then differ in one line each: where the configuration comes from.

```js title="server.mjs"
export function createNodeServer(env = process.env) {
  const handle = createFront(createService({ region: env.REGION ?? 'local' }))
  return createServer(toNodeListener(handle))
}
```

```js title="worker.mjs"
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](https://hono.dev/docs/getting-started/cloudflare-workers), 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`](https://github.com/senecajs/seneca-gateway)
exports a handler that takes a JSON message, checks it against an allow list of patterns, and calls
`act`. [`@seneca/gateway-express`](https://github.com/senecajs/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.

```bash
node --experimental-vm-modules --expose-gc --no-warnings demo.mjs
```

```text output
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](https://developers.cloudflare.com/workers/wrangler/configuration/),
not in local development. The stand-in in `edge-standin.mjs` reproduces both differences inside the
test process, using a Node [`vm`](https://nodejs.org/api/vm.html) context.

```js title="edge-standin.mjs"
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`](https://nodejs.org/api/perf_hooks.html), so waiting on a timer or a
socket costs nothing, which is the platform's own rule.

```js title="edge-standin.mjs"
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](https://developers.cloudflare.com/workers/platform/limits/), 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.

```bash
node --experimental-vm-modules --expose-gc --no-warnings --test front.test.mjs
```

```text output
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](https://developers.cloudflare.com/workers/runtime-apis/nodejs/process/),
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](https://developers.cloudflare.com/workers/observability/errors/) 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

- [Limit concurrency in a message worker](/howto/limit-concurrency-in-a-message-worker)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

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

[^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](https://developers.cloudflare.com/workers/runtime-apis/nodejs/), which
takes two flags, `no_nodejs_compat` and `no_nodejs_compat_v2`. The
[`process.env` it provides](https://developers.cloudflare.com/workers/runtime-apis/nodejs/process/)
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.

[^2]: [The limits page](https://developers.cloudflare.com/workers/platform/limits/) 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.

[^3]: Cloudflare's [local development page](https://developers.cloudflare.com/workers/local-development/)
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.