The Same App on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge — Verified
One Bookstore app, five entry files, five deployments. The Node serve(), the Bun handle.url, the Deno onListen, the Workers ctx.waitUntil, and Vercel's toWebHandler / toRouteHandlers / toFetchHandler — with receipts.
Hi, I'm Devlin. Ten years of fullstack work. I have personally been in a meeting where someone said "our framework runs anywhere" and then quietly listed five things it does not run on. So when DaloyJS says the same app runs on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge, I owe you receipts, not slides. This post is the receipts.
The plan: one Bookstore app, one src/app.tsshared across every runtime, and five entry files — one per platform. For each runtime we'll look at the adapter, the platform-specific options it handles for you (graceful shutdown, idle timeouts, ctx.waitUntil, the three Vercel shapes), and the sharp edges that are notthe adapter's fault but absolutely will bite you if you ignore them.
The shared app — note what's missing
Before the adapters, look at what the application file does not import. No http, no node:fs, no Deno.serve, no addEventListener. The shared code only ever sees Request in, Responseout. That's the whole reason this works.
One App instance, two routes, the usual requestId() + secureHeaders()pair. This is what we're going to deploy five times.
1. Node — the boring grown-up of the family
Node.js
@daloyjs/core/node- Wires SIGTERM and SIGINT to a graceful drain
- Sets server.requestTimeout and server.headersTimeout
- Tracks open sockets so shutdown actually closes them
- Optional x-forwarded-* trust for ALB / nginx scenarios
- WebSocket upgrade plumbed when ws routes exist
- Set shutdownTimeoutMs ≥ your slowest request
- Only set trustProxy: true behind a sanitizing proxy
- PORT bound by your platform (Heroku, Fly, etc.) — read it
serve() returns a small handle — { server, port, close } — so your tests can await handle.close() without doing the SIGTERM dance themselves. The auto-wired signals are what make Node deploys feel grown-up: hit Ctrl-C twice in dev and you get the same drain behavior as production, not a half-finished response and a stranded client.
2. Bun — the fast one with a friendly handle
Bun
@daloyjs/core/bun- handle.url — the resolved URL Bun is listening on
- Pass-through to Bun's native serve options (tls, unix, idleTimeout)
- WebSocket upgrade via server.upgrade() when routes exist
- Dev error pages with development: true
- No auto SIGTERM hook — Bun handles process lifecycle itself
- idleTimeout is in seconds, not milliseconds
- Bun's process.env polyfill is great; resist the urge to import bun:*
The thing I quietly love here is handle.url. Bun computes the scheme and port for you, so "what URL do I actually log on boot" stops being a paragraph of conditionals. One field, you're done. Small luxury, big quality-of-life.
3. Deno — permissions are not a chore, they're a feature
Deno
@daloyjs/core/deno- Modern Deno.serve under the hood
- onListen({ hostname, port }) for ergonomic startup logging
- SIGTERM / SIGINT auto-wired with a drain window
- Optional cert/key for HTTPS at the runtime layer
- Run with the smallest permission set: --allow-net --allow-env
- TLS files need --allow-read for the PEM paths
- Use Deno.env.get() in entry files; don't import process.env
Deno's permission model is the bit I miss the moment I'm back on Node. --allow-netby itself means "this process can open sockets" — it cannot read your home directory, your env vars, or your camera (yes, really). Pair it with --allow-env if your app reads any env vars and stop there. If a transitive dependency tries to escalate later, Deno tells you.
4. Cloudflare Workers — the real edge, with ctx.waitUntil
Cloudflare Workers
@daloyjs/core/cloudflare- Returns the { fetch } object Workers want — export default it
- Generic over your Env bindings: toFetchHandler<MyEnv>(app)
- Surfaces ctx.waitUntil / passThroughOnException through ctx.platform
- No process to crash — errors are returned as Response objects
- No node:* imports — your shared code must stay portable
- Workers' CPU limits are real; mind the 50ms cap on free plans
- Use waitUntil for fire-and-forget; don't fire-and-forget without it
ctx.waitUntilis the Cloudflare detail that most frameworks make awkward. The pattern you want is "respond to the user immediately, finish the analytics write in the background". If you skip waitUntil, the Worker isolate may be killed the instant the response is sent, and your background promise dies with it. The middleware above does it the right way: the response goes out, the analytics write keeps the isolate alive just long enough to land.
5. Vercel — same app, three shapes
Vercel is the runtime where "which export do I use" is actually the interesting question, because the platform has three distinct deployment patterns and each one wants a slightly different default export. The adapter has three exports to match:
Vercel (Edge + Node Functions + Next.js App Router)
@daloyjs/core/vercel- toWebHandler — bare (req: Request) => Response, ideal for Edge
- toFetchHandler — { fetch } object for Vercel Node.js Functions
- toRouteHandlers — { GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD } for App Router
- Edge runtime has no node:* — keep middleware portable
- Node functions can use process.env freely; Edge bundles secrets at build time
- App Router route.ts files want named exports, not default — use toRouteHandlers
All three of those files import the same app from src/app.ts. The only thing that changes is the adapter's shape. Pick the one that matches your deployment target and move on with your life.
The receipts
Let's prove this isn't marketing. Same app, five hosts. Same response. Same OpenAPI bytes.
The last command is the one I care about most: SHA-256 the /openapi.json from all five deployments, sort unique, count lines, and you get exactly 1. The contract the outside world sees is identical, byte-for-byte, regardless of which runtime is serving it. That is the entire point.
The one rule that makes this work: read env the native way
The most common cross-runtime bug I've had to debug — in my own code, painfully — is reaching for process.envin the shared app file. Don't. Read env in the entry file instead, using the native API of each runtime, and pass values into the app:
Yes, Bun polyfills process.env. Yes, Vercel Edge tolerates it for build-time bundling. The reason this rule still matters is that the moment your shared app.ts reads from a globally-mutable environment, your tests need to mock that global, and your Workers deployment needs you to remember which env vars get bundled when. Just hoist the reading. Future-you will apologize to current-you over an expensive Norwegian coffee.
The adapters at a glance
| Runtime | Import | Export | Specialty |
|---|---|---|---|
| Node.js | @daloyjs/core/node | serve() | SIGTERM drain, requestTimeout |
| Bun | @daloyjs/core/bun | serve() | handle.url, idleTimeout, unix |
| Deno | @daloyjs/core/deno | serve() | onListen, --allow-net, TLS opts |
| Cloudflare Workers | @daloyjs/core/cloudflare | toFetchHandler<Env>() | ctx.waitUntil, Env binding generic |
| Vercel Edge | @daloyjs/core/vercel | toWebHandler() | bare fetch handler |
| Vercel Node Functions | @daloyjs/core/vercel | toFetchHandler() | wraps in { fetch } |
| Next.js App Router | @daloyjs/core/vercel | toRouteHandlers() | named GET/POST/… exports |
The honest part
Runtime portability is not magic, and it's not free. It works because the shared application file is disciplined about two things — it never imports a runtime, and it never reads global state that is shaped differently per runtime. The adapter at the edge does the platform-shaped work, and the application in the middle does the application-shaped work. As long as you respect that boundary, the framework holds up its end.
The pleasant surprise is what this unlocks operationally. You can run the same suite of tests against an in-process client in CI (fast), against a Bun process on a preview environment (also fast), against a Workers deployment in canary (cheap to spin up, very real edge), and against your Node prod cluster (the boring grown-up). All five are the same code. The difference is one import.
Want to skip ahead and try it? pnpm create daloy@latest ships templates for Node, Bun, Deno, Cloudflare Workers, and Vercel Edge out of the box. Pick one, deploy it, then point a different adapter at the same src/app.ts the next morning. The scaffolding has done the boring parts for you.
Thanks for reading. Now if you'll excuse me, the sun in Oslo is being aggressive about not setting tonight, and I have five curls to run against five deployments to make sure this blog post stays true.
— Devlin