Node.js, Deno & Bun

For years, JavaScript could only run in one place: a browser, sandboxed away from your files and network sockets, wired to a document. Then someone bolted the same engine that runs JavaScript in Chrome onto a C library that talks to the operating system, and suddenly the language could open files, listen on ports, and spawn processes. That project was Node.js, and it turned a browser language into a general-purpose server platform almost overnight.

Today you have a choice of three serious server runtimes — Node, Deno, and Bun — and they increasingly speak the same web-standard dialect. This article is about what actually runs your code on a server: the pieces inside the runtime, the loop that schedules your callbacks, the modules you reach for constantly, and how the three runtimes differ where it matters.

What a runtime actually is

Your .js file is just text. Something has to parse it, compile it to machine code, run it, and — crucially — give it a way to reach the outside world. That “something” is the runtime, and it’s two parts glued together.

The first part is a JavaScript engine: V8, the same one in Chrome. It knows the language — objects, closures, promises, garbage collection — but it knows nothing about files or sockets. The language spec never mentions a filesystem.

The second part is everything the engine can’t do on its own: reading files, opening network connections, timers, cryptography. In Node this comes from libuv, a C library that wraps the operating system’s async I/O facilities and provides a thread pool for work that can’t be done asynchronously by the OS. The bindings between your JavaScript and libuv are what make fs.readFile possible.

your JavaScriptapp.js, node_modules, framework codeV8 engineparse · compile · GClibuvevent loop · thread poolkernel async I/Oepoll / kqueue / IOCP — socketsthread poolfs, dns, crypto, zlib (default 4)operating system · disk · network
A server runtime is your JavaScript on top of an engine (V8) on top of a systems layer (libuv) that talks to the OS. Fast async I/O goes through the kernel's event notifier; blocking work like file reads and DNS is handed to a small thread pool.

Deno and Bun are the same shape. Deno uses V8 plus a systems layer written in Rust (built on Tokio). Bun swaps the engine entirely for JavaScriptCore (the engine from Safari) and a systems layer written in Zig. The parts have different names, but the idea is identical: an engine that runs the language, and a native layer that reaches the OS.

The event loop, server-side

You’ve met the event loop in the browser. The mental model carries over: one main thread, a queue of callbacks, microtasks drained between tasks. But Node’s loop is libuv’s loop, and it’s organized into distinct phases that run in a fixed order every tick. Knowing the phases explains ordering puzzles that otherwise look like magic.

timerssetTimeout / setIntervalpendingdeferred I/O callbackspollwait + run I/OchecksetImmediateclose‘close’ eventsafter EACH callback:drain nextTick queue,then promise microtasksone full lap = one “tick” of the loop
libuv runs the loop as an ordered cycle of phases. Timers fire due callbacks, poll waits for and runs I/O, check runs setImmediate. After every single callback, Node fully drains the nextTick queue and then the promise microtask queue before continuing.

Three of those phases are the ones you’ll actually schedule into:

  • timers runs callbacks whose setTimeout/setInterval delay has elapsed.
  • poll is where the loop spends most of its life — waiting for and executing I/O callbacks (an incoming request, a finished file read).
  • check runs setImmediate callbacks, right after poll.

The subtle part is the two microtask queues that sit outside the phases. process.nextTick callbacks go in one; promise reactions and queueMicrotask go in the other. Node empties both — nextTick first, then promises — after every individual callback, not just between phases. That’s why process.nextTick is the highest-priority async primitive in Node, and why starving the loop with recursive nextTick calls is a real footgun.

The classic ordering puzzle: what does this print?

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

Walk it phase by phase.

Ordering across microtasks and phases
1/5
Variables
— nothing yet —
Console
sync
Top-level code runs to completion first. The timer, immediate, promise and nextTick are all just scheduled — nothing has fired yet.

The guaranteed part is that sync prints first, then nextTick, then promise — microtasks always clear before the loop touches any phase. The timeout vs immediate order at the top level actually depends on process timing and isn’t guaranteed; inside an I/O callback, though, setImmediate always wins.

Core modules you’ll use constantly

Node ships a standard library of built-in modules. A handful come up in almost every program. Import them with the node: prefix — it’s unambiguous and makes it clear you mean the built-in, not a package named fs.

fs and path

fs is the filesystem. Its functions come in three flavors: callback (fs.readFile), synchronous (fs.readFileSync), and promise-based (fs.promises / node:fs/promises). Prefer the promise API in new code.

import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const configPath = join(import.meta.dirname, 'config.json');
const raw = await readFile(configPath, 'utf8');
const config = JSON.parse(raw);

config.lastRun = new Date().toISOString();
await writeFile(configPath, JSON.stringify(config, null, 2));

Always build paths with path.join rather than string concatenation — it uses the right separator on every OS and collapses .. segments correctly. import.meta.dirname (stable since Node 21) gives you the current module’s directory in ESM, replacing the old CommonJS __dirname.

events and EventEmitter

Much of Node’s API is built on a single pattern: an object emits named events, and you subscribe with .on. EventEmitter is that pattern, exposed for your own use.

import { EventEmitter } from 'node:events';

class Job extends EventEmitter {
  async run() {
    this.emit('start');
    for (let i = 0; i <= 100; i += 25) {
      await new Promise(r => setTimeout(r, 50));
      this.emit('progress', i);
    }
    this.emit('done');
  }
}

const job = new Job();
job.on('progress', pct => console.log(`... ${pct}%`));
job.once('done', () => console.log('finished'));
job.run();

.on subscribes for every emission; .once auto-unsubscribes after the first. Servers, streams, sockets, and the process object are all emitters underneath.

streams

A stream processes data in chunks instead of loading it whole. Reading a 4 GB log file with readFile tries to hold all 4 GB in memory; a read stream hands you a chunk at a time. The killer feature is pipe, which connects a readable to a writable and handles backpressure — pausing the source when the destination can’t keep up.

import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';

// Compress a file without ever holding it all in memory.
await pipeline(
  createReadStream('access.log'),
  createGzip(),
  createWriteStream('access.log.gz'),
);
readaccess.loggziptransformwrite.log.gzchunks flow one at a time · memory stays constant
A pipeline moves fixed-size chunks through each stage. The source only produces the next chunk when the downstream stage is ready — backpressure keeps memory flat no matter how large the file.

http, process, and Buffer

http is the raw web server. You rarely use it directly — frameworks wrap it — but it’s worth seeing once, because everything else is sugar over this:

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ path: req.url }));
});

server.listen(3000, () => console.log('listening on :3000'));

process is your handle on the running program: process.env for environment variables, process.argv for command-line arguments, process.exit(code) to quit, and events like process.on('SIGTERM', ...) for graceful shutdown. Buffer is Node’s original binary type, a subclass of Uint8Array — you’ll see it whenever you touch raw bytes, though modern code increasingly uses the standard Uint8Array directly.

CommonJS vs ESM in Node

Node predates standard ES modules, so it grew up on CommonJSrequire() and module.exports. Standard ESMimport/export — arrived later, and both still work. We covered the module systems in depth elsewhere; here’s what’s specific to Node.

Node decides which system a file uses from three signals:

  • A .mjs extension is always ESM; a .cjs extension is always CommonJS.
  • A plain .js file follows the nearest package.json: "type": "module" means ESM, "type": "commonjs" (or absent) means CommonJS.

Running scripts and npm scripts

You run a file with node app.js. Everything else — starting a dev server, running tests, building — conventionally lives in the scripts field of package.json, so the commands travel with the project.

{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "test": "node --test"
  }
}

Run them with npm run dev (or just npm test / npm start for the two special-cased names). Two flags worth knowing: --watch restarts the process when a file changes, no nodemon needed, and --env-file=.env loads environment variables from a file without the old dotenv dependency. Node also ships a built-in test runner (node --test) and assertion library (node:test, node:assert), so a small project needs no test framework at all.

Native TypeScript in Node (the 2026 shift)

The biggest recent change to Node is that you can now run TypeScript directly. node app.ts just works — no ts-node, no build step, no config.

The mechanism is deliberately simple and worth understanding, because it explains the limits. Node does type stripping: it parses your .ts file, erases the type annotations by replacing them with whitespace, and runs the resulting JavaScript. It does no type checking and no transformation — a : string annotation becomes blank space, and that’s it.

greet.tsfunction greet( name: string): string { return ’hi ’ + name;}stripruns as JSfunction greet( name) { return ’hi ’ + name;}
Type stripping erases annotations in place, replacing them with whitespace so line and column numbers are preserved for stack traces. What runs is plain JavaScript. No type checking happens at runtime — that stays a job for your editor and tsc.

This shipped incrementally: behind --experimental-strip-types in Node 22, on by default in 23.6, and stable in the 24 LTS and 26 lines, with the experimental flag gone entirely in Node 26. As of mid-2026 it’s a production feature.

The catch is that type-stripping only handles TypeScript syntax that erases cleanly. Anything that would need real code generation is rejected:

enum Color { Red, Green }      // ✗ enums emit a runtime object
namespace N { export let x = 1 } // ✗ runtime namespace
class C { constructor(private id: string) {} } // ✗ parameter property

Enums, runtime namespaces, parameter properties, and legacy decorators all fail, because there’s nothing to strip — they’d have to be compiled. There’s also one gotcha that bites people: type-only imports must use the type keyword, or Node treats them as real value imports and crashes at runtime.

import type { User } from './types.ts';        // ✓ erased
import { createUser, type UserInput } from './api.ts'; // ✓ mixed, type part erased

If you genuinely need enums or decorators at runtime, use a tool that transforms rather than strips — tsx is the common choice (node --import tsx app.ts) and Deno and Bun both transform by default.

Deno and Bun

Node won by being first and staying compatible. The two challengers each pick a different thing to be better at.

Deno was built by Node’s original creator to fix what he saw as early mistakes. Its headline feature is security by default: a script can’t read files, hit the network, or read environment variables unless you grant the permission on the command line.

deno run app.ts                         # no file, net, or env access
deno run --allow-net --allow-read app.ts # explicit grants only

That sandbox is a real distinction — a random dependency can’t quietly read your ~/.ssh or phone home unless you allowed the relevant capability. Deno also bundles a formatter, linter, test runner, and a standard library, and runs TypeScript with full transformation (enums and all). Deno 2 added deep Node and npm compatibility, so package.json and most npm packages now work, which removed the main reason people avoided it.

Bun optimizes for one thing above all: speed, and being a single tool that does everything. It’s a runtime and a package manager and a bundler and a Jest-compatible test runner, all in one binary built on JavaScriptCore.

bun install        # a very fast npm-compatible installer
bun run app.ts     # run TS directly, no config
bun test           # built-in Jest-style test runner
bun build ./app.ts # built-in bundler

bun install is dramatically faster than npm install on cold caches, and Bun aims for near-total Node compatibility (~98% of npm packages by 2026), so it’s often a drop-in that just runs your existing project quicker.

NodeDenoBunruns TypeScriptstriptransformtransformpermission sandboxopt-indefaultnobuilt-in bundlernodeprecatedyesbuilt-in test runneryesyesyespackage managernpm (separate)built-inbuilt-inengineV8V8JavaScriptCorenpm ecosystem fit100%~95%~98%
Where the three runtimes differ. Node leans on the npm ecosystem and third-party tools; Deno leads on a permission sandbox and bundled tooling; Bun bundles everything into one fast binary. All three now run TypeScript out of the box (Node by stripping, the others by transforming).

The convergence: one program, three runtimes

Here’s the part that makes the choice lower-stakes than the marketing suggests. All three runtimes have adopted the web platform standards you already know from the browser. fetch, Request, Response, URL, Web Streams, AbortController, structuredClone, TextEncoder — these are globals in Node, Deno, and Bun alike. Code written against them runs unchanged everywhere.

// Identical source. Runs on Node 24+, Deno, and Bun with no changes.
const res = await fetch('https://api.github.com/repos/nodejs/node');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const repo = await res.json();
console.log(`${repo.full_name} — ${repo.stargazers_count} stars`);
await fetch(url)web-standard global · one source fileNodeundici under the hoodDenoneeds –allow-netBunnative implementationsame call · same result · three engines
The same web-standard fetch call is a global in all three runtimes. Portable code targets these shared APIs rather than a runtime's private modules, so switching runtimes becomes a deployment decision, not a rewrite.

The practical upshot: write against web standards where you can, and reach for a runtime’s private modules (node:fs, Deno.readFile, Bun.file) only when there’s no standard equivalent. That keeps your options open.

Which one should you pick?

You don’t have to marry one. Because the web-standard core is shared, it’s normal to develop against Node’s huge ecosystem and swap the runtime later if a deployment target favors another. The interesting story of the last few years isn’t which runtime won — it’s that they’ve converged enough that the question matters less than it used to.

Summary

  • A runtime is a JavaScript engine (V8, or JavaScriptCore in Bun) plus a native systems layer (libuv in Node) that reaches the OS for file, network, and timer work.
  • Node’s event loop runs libuv’s ordered phases — timers, poll, check, and others. After every callback it drains process.nextTick then promise microtasks, which explains ordering and makes nextTick the highest-priority (and most dangerous) async primitive.
  • Core modules cover the essentials: fs/path for files, events for the emitter pattern, stream for chunked data with backpressure, http for servers, process for the environment, and Buffer for bytes.
  • Node speaks both CommonJS and ESM; default new projects to "type": "module". As of Node 22, require() can even load synchronous ESM.
  • Native TypeScript is stable in Node’s 24 and 26 lines: Node strips types (no checking, no transform), so enums, runtime namespaces, and parameter properties fail — keep tsc --noEmit in CI and use erasableSyntaxOnly.
  • Deno leads on a default permission sandbox and bundled tooling; Bun leads on speed and being an all-in-one binary. All three now run TypeScript out of the box.
  • The runtimes have converged on web standards (fetch, Web Streams, URL, AbortController). Target those and switching runtimes becomes a deployment choice, not a rewrite.