RuntimesReceipts

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.

Devlin DuldulaoFullstack cloud engineer14 min read

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.

apps/bookstore/src/app.ts
ts
// apps/bookstore/src/app.ts — shared by all five runtimes
import { z } from "zod";
import { App, requestId, secureHeaders } from "@daloyjs/core";

export const app = new App({
  bodyLimitBytes: 256 * 1024,
  requestTimeoutMs: 5_000,
  production: process.env.NODE_ENV === "production",
});

app.use(requestId());
app.use(secureHeaders());

app.route({
  method: "GET",
  path: "/health",
  operationId: "getHealth",
  responses: {
    200: { description: "OK", body: z.object({ status: z.literal("ok") }) },
  },
  handler: async () => ({ status: 200, body: { status: "ok" } }),
});

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBookById",
  request: { params: z.object({ id: z.string().min(1) }) },
  responses: {
    200: {
      description: "Found",
      body: z.object({ id: z.string(), title: z.string() }),
    },
  },
  handler: async ({ params }) => ({
    status: 200,
    body: { id: params.id, title: `Book ${params.id}` },
  }),
});

// Notice: not a single runtime-specific import. No node:fs, no Deno.serve,
// no addEventListener("fetch"). The app only knows Request -> Response.
● shared by all 5 entry files

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
handles for you
  • 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
mind the
  • Set shutdownTimeoutMs ≥ your slowest request
  • Only set trustProxy: true behind a sanitizing proxy
  • PORT bound by your platform (Heroku, Fly, etc.) — read it
apps/bookstore/src/server.node.ts
ts
// apps/bookstore/src/server.node.ts
import { serve } from "@daloyjs/core/node";
import { app } from "./app.js";

const handle = serve(app, {
  port: Number(process.env.PORT ?? 3000),
  hostname: "0.0.0.0",
  // Caps both server.requestTimeout and server.headersTimeout.
  connectionTimeoutMs: 30_000,
  // SIGTERM / SIGINT auto-wired; drain window is here.
  shutdownTimeoutMs: 10_000,
  handleSignals: true,
  // If a load balancer sets x-forwarded-*, trust it.
  trustProxy: true,
});

console.log(`listening on http://localhost:${handle.port}`);
serve · port 3000 · SIGTERM ✓ · drain ≤ 10s

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
handles for you
  • 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
mind the
  • 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:*
apps/bookstore/src/server.bun.ts
ts
// apps/bookstore/src/server.bun.ts
import { serve } from "@daloyjs/core/bun";
import { app } from "./app.ts";

const handle = serve(app, {
  port: Number(process.env.PORT ?? 3000),
  idleTimeout: 30,            // seconds
  // development: true,       // pretty error pages while building
  // unix: "/tmp/api.sock",   // unix socket instead of TCP
});

// handle.url is the resolved URL Bun is actually listening on,
// including scheme + port. Saves you one console.log argument.
console.log(`listening on ${handle.url ?? `http://localhost:${handle.port}`}`);
bun run · idleTimeout 30s · handle.url ✓

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
handles for you
  • 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
mind the
  • 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
apps/bookstore/src/server.deno.ts
ts
// apps/bookstore/src/server.deno.ts
import { serve } from "@daloyjs/core/deno";
import { app } from "./app.ts";

serve(app, {
  port: Number(Deno.env.get("PORT") ?? 3000),
  onListen: ({ hostname, port }) => {
    console.log(`listening on http://${hostname}:${port}`);
  },
  // SIGTERM / SIGINT auto-wired; same shape as Node.
  handleSignals: true,
  shutdownTimeoutMs: 10_000,
});

// Run it with the smallest set of permissions you can get away with:
//   deno run --allow-net --allow-env src/server.deno.ts
//
// Need TLS? Add cert/key options and --allow-read for the PEM files.
deno run --allow-net --allow-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
handles for you
  • 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
mind the
  • 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
apps/bookstore/src/worker.ts
ts
// apps/bookstore/src/worker.ts
import { toFetchHandler } from "@daloyjs/core/cloudflare";
import { app } from "./app";

// Type your bindings; toFetchHandler is generic over Env.
interface MyEnv {
  ANALYTICS: AnalyticsEngineDataset;
  KV: KVNamespace;
}

// Background work that must outlive the response: ctx.waitUntil.
app.use(async (ctx, next) => {
  const start = Date.now();
  const res = await next();
  const env = ctx.platform.env as MyEnv | undefined;
  const wait = ctx.platform.ctx?.waitUntil;
  if (env && wait) {
    wait(
      env.ANALYTICS.writeDataPoint({
        blobs: [ctx.request.method, new URL(ctx.request.url).pathname],
        doubles: [Date.now() - start, res.status],
      }),
    );
  }
  return res;
});

export default toFetchHandler<MyEnv>(app);
wrangler deploy · ctx.waitUntil ✓

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
handles for you
  • 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
mind the
  • 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
apps/bookstore/api/[...path].tsapps/bookstore/api/[...path].node.tsapps/web/app/api/[...slug]/route.ts
ts
// 1) Vercel Edge function — runtime: "edge"
// apps/bookstore/api/[...path].ts
import { toWebHandler } from "@daloyjs/core/vercel";
import { app } from "../src/app";

export const config = { runtime: "edge" };

// toWebHandler returns a bare (req: Request) => Response — Edge default export.
export default toWebHandler(app);


// 2) Vercel Node.js function — default runtime
// apps/bookstore/api/[...path].ts
import { toFetchHandler } from "@daloyjs/core/vercel";
import { app } from "../src/app";

// toFetchHandler wraps it in { fetch }, which the Node Vercel runtime expects.
export default toFetchHandler(app);


// 3) Next.js App Router — route handlers
// apps/web/app/api/[...slug]/route.ts
import { toRouteHandlers } from "@daloyjs/core/vercel";
import { app } from "@/lib/api/app";

// Spreads into the named HTTP method exports Next.js wants.
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD } =
  toRouteHandlers(app);
3 shapes · same app

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.

bash
# Same request, five hosts. Same response body. Same OpenAPI document.

$ curl -s https://node.example.dev/health    | jq .
{ "status": "ok" }

$ curl -s https://bun.example.dev/health     | jq .
{ "status": "ok" }

$ curl -s https://deno.example.dev/health    | jq .
{ "status": "ok" }

$ curl -s https://cf.example.dev/health      | jq .
{ "status": "ok" }

$ curl -s https://vercel.example.dev/health  | jq .
{ "status": "ok" }

# And the spec — generated from the same routes, byte-identical across runtimes:
$ for host in node bun deno cf vercel; do
    curl -s "https://$host.example.dev/openapi.json" | sha256sum
  done | sort -u | wc -l
1

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:

apps/bookstore/src/server.*.ts
ts
// Subtle but important: read environment THE NATIVE WAY per runtime.
// Don't sprinkle process.env across your shared app/handlers.

// Node / Bun: process.env exists (Bun polyfills it).
const port = Number(process.env.PORT ?? 3000);

// Deno: Deno.env.get(...).
const port = Number(Deno.env.get("PORT") ?? 3000);

// Cloudflare Workers: arrive through the env arg of fetch().
// Hoist them in a tiny edge config layer; don't reach for process.env.
//
// Vercel: process.env works on Node functions; on Edge use process.env too,
// but accept that secrets are bundled at build time per their platform docs.
rule: env stays in the entry file

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

RuntimeImportExportSpecialty
Node.js@daloyjs/core/nodeserve()SIGTERM drain, requestTimeout
Bun@daloyjs/core/bunserve()handle.url, idleTimeout, unix
Deno@daloyjs/core/denoserve()onListen, --allow-net, TLS opts
Cloudflare Workers@daloyjs/core/cloudflaretoFetchHandler<Env>()ctx.waitUntil, Env binding generic
Vercel Edge@daloyjs/core/verceltoWebHandler()bare fetch handler
Vercel Node Functions@daloyjs/core/verceltoFetchHandler()wraps in { fetch }
Next.js App Router@daloyjs/core/verceltoRouteHandlers()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