RE:NODE

Bun or Deno on a small server: which runtime to pick

By RE:NODE Team
19 min read


For a small app on a small server, Node.js is still the safe default, Bun is the one to try when you want the shortest path from a TypeScript file to a running HTTP server, and Deno is the one to pick when you want the runtime to refuse to do things you did not ask for. All three run the same kind of program: a long-lived process that listens on a port and answers HTTP. The differences that matter on a hosted plan are small and practical - how the server is started, how dependencies are installed, how TypeScript is handled, what the lockfile is called, and which packages from npm quietly fail. This post goes through each of those with real commands, and ends with how to choose and what to check when the app starts but nothing answers.

There are no benchmark numbers in here on purpose. Throughput and memory figures for runtimes depend on the version, the workload and the machine, and the numbers in most comparison posts are for a hello-world handler that no real app resembles. Where memory matters, the advice is to measure your own app on your own plan.

What each runtime is#

Node.js is the incumbent. It runs JavaScript on the V8 engine, ships with npm, and has by far the largest body of tutorials, hosting support and battle-tested libraries. TypeScript is not run directly by default: you compile it, or use a loader or a flag that strips types, and what is supported depends on the Node version you run.

Bun is a single executable that is a runtime, a package manager, a test runner and a bundler. It uses the JavaScriptCore engine rather than V8, aims for compatibility with Node's APIs and with npm packages, and runs .ts and .tsx files directly. Its own server API is Bun.serve. Its package manager is bun install, and the lockfile is bun.lock.

Deno is a runtime built around a security model and web-standard APIs. It runs TypeScript directly, ships with a formatter, linter, test runner and type checker, and runs code with no access to the network, environment or file system until you grant it. Its server API is Deno.serve. Configuration lives in deno.json, and tasks are defined there.

The first thing to settle is that these are not three flavours of the same thing. They share a language and a large part of the standard web API surface - fetch, Request, Response, URL, AbortSignal, streams - which is why a small handler can often be written once and moved between them. Beyond that they diverge, and the divergence is where the time goes.

RuntimeEngineDefault TypeScript story
Node.jsV8Compile first, or a version-dependent flag
BunJavaScriptCoreRuns .ts files directly
DenoV8Runs .ts files directly, with an optional type check

Check the version you actually have before relying on any behaviour in this post. All three move quickly, and node --version, bun --version and deno --version are the first lines of any bug report.

A server in Bun#

The smallest useful Bun server is one file:

server.ts
const port = Number(process.env.PORT) || 3000;const server = Bun.serve({  port,  hostname: "0.0.0.0",  fetch(req) {    const url = new URL(req.url);    if (url.pathname === "/healthz") {      return Response.json({ ok: true, uptime: process.uptime() });    }    return new Response("hello from bun\n");  },});console.log(`listening on ${server.port}`);

Run it with bun run server.ts, or just bun server.ts. The handler takes a standard Request and returns a standard Response, so there is no framework to install. Routing is yours to write, which is fine for a handful of endpoints and tedious beyond that. Frameworks built on web-standard requests, such as Hono and Elysia, run on Bun and are the usual step up. Express also runs on Bun through its Node compatibility layer, with the caveats in the compatibility section below.

Three details are worth knowing. First, hostname: "0.0.0.0" is spelled out here for clarity; the point is that the server must listen on all interfaces, because a server bound to loopback inside a container is unreachable from the proxy in front of it. Second, Bun reads a .env file in the working directory automatically, and both process.env and Bun.env expose the variables. That is convenient locally and a trap on a server, where a stray .env committed to the repository will quietly override your intentions or leak a secret. Keep real secrets in the environment. Third, bun --hot server.ts reloads on change during development, which is not something to run in production.

Dependencies come from bun install. It reads package.json, writes node_modules, and records the tree in bun.lock. Older Bun versions wrote a binary file called bun.lockb; current versions write the text bun.lock, which diffs properly in a pull request. If your repository holds an old bun.lockb, check which one your Bun version reads before you delete either. For a deploy, the equivalent of npm ci is:

bash
$ bun install --frozen-lockfile

That installs exactly what the lockfile says and fails if package.json and the lockfile disagree, for the same reason npm ci exists. Add --production to skip devDependencies when nothing in your start path needs them.

A server in Deno#

The same program in Deno:

main.ts
const port = Number(Deno.env.get("PORT")) || 8000;Deno.serve({ port, hostname: "0.0.0.0" }, (req: Request) => {  const url = new URL(req.url);  if (url.pathname === "/healthz") {    return Response.json({ ok: true });  }  return new Response("hello from deno\n");});

Deno.serve takes an options object and a handler, and the handler is again a function from Request to Response. The default port is 8000 if you pass none, and the default hostname listens on all interfaces, but writing both down costs nothing and removes a guess from the troubleshooting later.

Running it is where Deno differs most, because of permissions:

bash
$ deno run --allow-net --allow-env=PORT main.ts

Without --allow-net the program is refused at the moment it tries to open the socket. Without --allow-env the call to Deno.env.get fails with a permission error (or prompts, if you run it interactively). The model is deny by default, and it is genuinely useful for a small server: a dependency that suddenly wants to read your environment or open a connection somewhere stops with an error rather than succeeding silently.

Project configuration goes in deno.json, and the part you will use on a host is tasks:

deno.json
{  "tasks": {    "start": "deno run --allow-net --allow-env=PORT,DATABASE_URL main.ts",    "dev": "deno run --watch --allow-net --allow-env main.ts",    "check": "deno check main.ts"  },  "imports": {    "hono": "npm:hono@4"  }}

deno task start runs the command in the task. The imports block is an import map: it gives bare names to packages so your code can write import { Hono } from "hono". The npm: prefix pulls a package from the npm registry, and jsr: pulls from JSR, Deno's own registry. Deno writes a deno.lock file to pin what it fetched, and the --frozen flag makes an install or run fail if the lockfile would change. If you keep a package.json in a Deno project, current versions read it and can install into node_modules; that is the route to take when you are moving an existing Node project rather than starting fresh.

Permission flags, and what to grant#

Permissions are the main reason to choose Deno, and they are worth using properly instead of reaching for the allow-everything flag.

FlagGrantsNarrow form
--allow-netNetwork access--allow-net=0.0.0.0:8000,api.example.com
--allow-envReading environment variables--allow-env=PORT,DATABASE_URL
--allow-readReading files--allow-read=./static,./data
--allow-writeWriting files--allow-write=./data

There are more (--allow-run for spawning processes, --allow-ffi, --allow-sys), and -A or --allow-all switches the lot on. For a server that listens, reads two environment variables and serves a directory, the honest set is the first three above, each narrowed:

bash
$ deno run \    --allow-net=0.0.0.0:8000,db.internal:5432 \    --allow-env=PORT,DATABASE_URL \    --allow-read=./static \    main.ts

Two cautions. The narrow forms are only as good as your list: a library you added last month may need an extra environment variable, and the failure is a permission error at the first request that touches it, not at startup. Run the app through its real paths before trusting the list. And -A is a legitimate choice for a private tool, but then you have opted out of the feature that justified the runtime, so be honest with yourself about it.

Bun and Node have no equivalent switch. Newer Node versions have an experimental permission model behind a flag, and its status changes between releases, so read the documentation for the version you run before building on it.

TypeScript without a build step#

The quiet appeal of both Bun and Deno is that main.ts runs as is. There is no tsc, no dist directory, no source maps to keep aligned with a running process, and no devDependency on the compiler, which sidesteps the tsc: not found trap described in the npm ci post. For a small app that removes a whole stage from the deploy.

What you get is slightly different in each:

  • Bun transpiles TypeScript by stripping and transforming it, and does not type check. A type error does not stop the program; it simply is not noticed. Run tsc --noEmit in CI if you want the checks.
  • Deno also runs the file without insisting on a check by default for deno run, and offers deno check to type check explicitly. Putting deno check main.ts in CI, or as a task, gives you the safety net.
  • Node can run TypeScript in recent versions by stripping types, with limits on which TypeScript features are supported. If you depend on that, pin the Node version and read the notes for it.

The practical rule is the same everywhere: running TypeScript is not the same as checking it. The runtime will happily start a program with a type error in it, and the first you hear is a TypeError in the log at runtime. Check types in CI or before you push, and treat the server as the place the program runs, not where it is verified.

There is one more catch. TypeScript features that need code generation, such as legacy decorators and some uses of enum and namespace, are handled differently by different tools. Settings in tsconfig.json such as experimentalDecorators or paths are read by Bun; Deno reads compiler options from deno.json and has its own view of module resolution, including requiring explicit file extensions in relative imports. A project written for a bundler that resolves ./utils without an extension will fail on Deno until the imports say ./utils.ts.

npm compatibility and what breaks#

Most of npm works on both. Pure JavaScript packages almost always do, and so do the popular frameworks. The failures cluster in a few places, and it is worth knowing them before you move an existing project.

  • Native addons. Packages that compile C or C++ at install time and load a .node binary, such as some image, crypto and database drivers, depend on Node's native addon interface. Bun supports Node-API for many of them and Deno supports it behind permission, but a package that reaches into V8 internals will not load. Prefer packages that ship WebAssembly or have a pure JavaScript fallback.
  • Install scripts. bun install does not run the lifecycle scripts of arbitrary dependencies by default; it keeps a list of trusted packages and you add to it with trustedDependencies in package.json. A package that needs postinstall to download a binary (Prisma's engines, Puppeteer's browser, sharp's platform build in some setups) can arrive without the file it needs. Deno also requires you to opt in to lifecycle scripts, with an --allow-scripts flag on install.
  • Node built-ins at the edges. Both runtimes implement most of node:fs, node:http, node:crypto, node:stream and friends, and both aim to track Node's behaviour. The gaps are in the rarely used corners: node:cluster, node:vm details, some worker_threads behaviour, node:inspector, and the exact timing of streams and events. Code that depends on subtle behaviour is where a port goes wrong.
  • Frameworks that assume a Node server object. Express on Bun works through the node:http compatibility layer. Express on Deno works through npm:express with the same layer and the right permissions. Both paths work, but you are running Node's API on a non-Node runtime, and the bug that appears will be in the seam. A framework built for web-standard Request and Response avoids the seam entirely.
  • Tooling that expects Node. Anything that shells out to node, reads process.versions.node to branch, or is documented only for npm run and npx needs a check. bunx and deno run npm:package replace npx.

The way to find out is boring: install, start the app, call every route, run the test suite under the new runtime, and read the log for warnings. A package that is "supported" in the changelog can still fail in the one code path you use. If the project is mostly a pile of Node-specific dependencies, the cost of moving usually outweighs what you gain.

Memory, startup and the small-plan question#

People move to Bun or Deno hoping for lower memory use or faster starts. Sometimes they get it; sometimes they do not. This is where a published number would be dishonest, so here is what can be said safely.

Resident memory depends on the engine's baseline, the amount of code loaded, the size of your data in memory, the garbage collector's habits and the allocator. Two runtimes running the same app can differ in either direction, and the idle figure rarely predicts the figure under load. JavaScriptCore and V8 collect garbage differently, and a process that looks lean when idle can still grow under a particular request pattern. Neither engine is a guarantee of a smaller footprint.

What you can do is measure the thing you will actually run:

  1. Start the app on the plan you intend to buy, with a realistic copy of its data.
  2. Watch the memory graph in the console while you replay representative traffic, including the largest request your app handles.
  3. Leave it running for a day. A leak looks like a line that never comes back down.
  4. Compare against the plan limit, not against a number from a blog.

Memory limits behave in a specific way on RE:NODE: a container that reaches its limit is stopped and restarted clean instead of swapping. The consequence on Bun and Deno is the same as on Node - a leak shows up as a restart every few hours. Three restarts in an hour raise a warning on the server page and open a ticket automatically. Node memory limits explained covers heap flags in V8; they do not carry over to Bun, which has its own settings, so do not paste Node flags into a Bun start command and expect them to be honoured.

Startup time is the other claim. A TypeScript file that runs directly saves the compile step, and Bun's installer is quick, but on a hosted plan the number that matters is how long a deploy takes from push to answering, and that is dominated by dependency installation and your own boot work such as database connections and migrations. Measure the deploy, not the hello-world.

Process, environment and port handling#

The same rules apply to all three runtimes, and they are the rules that cause most "it works locally" tickets.

Read the port from the environment. Hard-coding a port means the app binds somewhere nothing routes to. On RE:NODE an app plan has one port allocation, shown on the Network tab, and environment variables are set on the Startup tab. Use the variable name your plan shows rather than guessing it, and fall back to a default only for local runs.

Bind to all interfaces. 0.0.0.0 in Bun.serve and Deno.serve, and the equivalent in any framework. A server that logs "listening" on 127.0.0.1 and answers nothing is the most common failure, and it produces connection refused from the proxy with no line in your own log.

Fail fast on missing configuration. The pattern from the Express production post carries over unchanged:

javascript
const required = ["DATABASE_URL", "SESSION_SECRET"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing configuration: ${missing.join(", ")}`);  process.exit(1);}

Under Deno, replace process.env[name] with Deno.env.get(name), and remember the --allow-env list has to include every name you read. Newer Deno versions also support process.env through the Node compatibility layer, but it is cleaner to pick one style per project. Where the secrets live is the subject of environment variables and secrets.

Handle SIGTERM. A restart or a deploy sends a termination signal, and a server that ignores it is killed after a grace period, dropping requests in flight. In Bun and Node, process.on("SIGTERM", ...) works, and in Bun you stop accepting connections with server.stop(). In Deno, use Deno.addSignalListener("SIGTERM", ...) and server.shutdown() on the object Deno.serve returns. The general shape, including health endpoints, is in graceful shutdown and health checks.

Log to stdout. The console shows it unfiltered and live. Structured JSON lines are worth the extra five minutes.

Running it on RE:NODE#

RE:NODE has separate Bun and Deno lines in the app category, each starting at $4 a month for 1 GB of memory, 5 GB of disk and half a vCPU, with larger tiers above that. They sit beside Node.js, Python, Go, Java, Laravel and C#, and the panel is the same on all of them: console, file manager, SFTP, backups and the Startup tab. Plans and current prices are on the app hosting page.

A few things are specific to these two lines:

  • A starting page. Every runtime line except Node.js starts a new server with a small working page on its address until you deploy your own code, and it never overwrites your files. That is a quick way to confirm the address and the port allocation work before your code is involved. Node.js servers do not have this page.
  • Deploy from GitHub. The Git deploy feature is available on all app lines: GitHub only, through a GitHub App with short-lived tokens so private repositories work. Two switches control it - pull on every start, and deploy on push, which restarts a running server and starts a stopped one - and each deploy leaves a record. The walkthrough for Node in deploy a Node.js app from GitHub maps closely onto the other lines.
  • The proxy slot. App plans include a proxy slot. Point an A record at the address shown and the certificate is issued and renewed automatically inside a 21-day window; the real client address arrives in X-Forwarded-For. In Bun.serve and Deno.serve there is no trust proxy setting, so read the header yourself and, if you trust one hop, take the last entry. What a reverse proxy does and pointing a domain at your server cover the details.
  • No process manager needed. The panel restarts a process that exits, so you do not need PM2 or a supervisor. PM2 or a hosting panel is the honest comparison.
  • Database slots. App plans include two database slots, created in the panel with generated credentials, which you pass to the app as environment variables.

The things not to assume are the ones the panel does not say. This post does not tell you the exact start command a new Bun or Deno server uses or which variable name carries the port, because those are worth reading off your own Startup tab. Treat the hello-world page as proof of the address, then replace it.

The install and start commands for a typical deploy are short:

bash
# Bunbun install --frozen-lockfile && bun run server.ts# Denodeno task start

Choosing between Node, Bun and Deno#

There is no universal answer, and the following is judgement rather than measurement.

Choose Node.js when you have an existing app with a pile of npm dependencies, when you rely on native addons, when the team already knows it, or when you want the least surprising behaviour. Almost every deployment problem you will meet already has a written answer for Node. The cost is a TypeScript compile step, which is small.

Choose Bun when you are starting a new TypeScript service, you want one tool for install, run and test, and your dependencies are plain JavaScript or well known to work on Bun. It is the closest to a drop-in for a Node project, because it reads package.json and node_modules, so a trial costs an afternoon rather than a rewrite. Run your tests under it before committing.

Choose Deno when the permission model appeals to you, when you want deno check, deno fmt and deno lint built in, or when you are writing against web-standard APIs and importing from JSR. It is a good fit for a small API with few dependencies. It is a less comfortable fit for a large Express codebase that was never written with permissions in mind.

If you are unsure, build the app on web-standard Request and Response with a small framework such as Hono, keep dependencies pure JavaScript, and you will be able to move between all three with a changed entry file. That portability is worth more than any claimed speed difference.

Troubleshooting#

The server starts and nothing answers. Bind address or port. Check that the host is 0.0.0.0 and that the port is the one the plan allocates. Open the Network tab, compare, and look at the log line the app prints.

`error: Cannot find module` on Bun after a deploy. The install did not run, or ran with the wrong flags. Run bun install --frozen-lockfile in the start command and read the output in the console. If a package needs a postinstall step, add it to trustedDependencies.

`error: Requires net access` or `Requires env access` on Deno. A permission is missing. The error names the permission and, often, the exact host or variable. Add the narrow flag rather than -A.

`error: Relative import path "x" not prefixed with / or ./ or ../`. Deno needs either a relative path with an extension or a mapping in imports in deno.json. Add the mapping.

Lockfile mismatch on install. bun install --frozen-lockfile and Deno's --frozen fail when the manifest changed without the lockfile. Regenerate locally, commit, deploy. Do not drop the flag on the server.

A package works locally and fails on the server. Platform-specific optional dependencies, native binaries or a missing postinstall. Generate the lockfile on a Linux machine or in CI, and check the package's notes for your runtime.

The memory graph climbs and then the server restarts. A leak, an unbounded cache or a large payload held in memory. Read the memory section above, bound your caches, and check the console for the restart pattern. Reading the console covers what to look for.

A WebSocket connects locally and drops through the proxy. Idle timeouts and upgrade handling in the proxy path. See WebSockets behind a reverse proxy.

FAQ#

Is Bun production ready?

It is used in production by many teams, and it is also younger than Node with a less complete compatibility record. Whether it is ready for your app depends on your dependencies. Run the test suite under it, load test the routes you care about, and keep the ability to fall back to Node for a release or two.

Can I run an existing Express app on Bun or Deno?

Usually, yes, through the Node compatibility layers, but treat it as a migration with a test pass rather than a flag flip. On Deno you add npm: specifiers or a package.json, plus the permissions. Watch native modules and anything that depends on Node internals.

Do I need a build step for TypeScript?

Not on Bun or Deno, which run .ts files directly. You still need a type check somewhere, because neither start command rejects a type error. Put tsc --noEmit or deno check in CI.

Which uses less memory, Bun or Deno?

It depends on the app, the versions and the load, and any single figure would be a guess. Measure your own app on the plan you intend to use and compare the graph against the limit.

What is the Bun lockfile called?

bun.lock, a text file, in current versions. Older versions wrote a binary bun.lockb. Commit the lockfile and install with bun install --frozen-lockfile on the server.

Do Deno permission flags work on a hosted plan?

Yes. They are flags on deno run, so they go in the start command or in a deno.json task. They limit your process; the hosting container is a separate layer of isolation.