What is Node.JS? Using JavaScript without the Browser

Node.js took the V8 engine out of Chrome and ran it on a server. That sounds like a small change. What it actually did was flip how developers think about I/O — and spawn an ecosystem of two million packages.

Before Node.js, JavaScript lived in the browser. Full stop. If you wanted a backend, you picked a different language. PHP was everywhere. Java ran enterprise backends. Ruby on Rails was having its moment. The idea of running JavaScript on a server was a strange thought, not a serious option.

Then in 2009, Ryan Dahl took V8, Chrome's JavaScript engine, stripped out the browser-specific parts, wrapped it in a C++ application with a library for I/O, and released Node.js. The web development world spent the next few years arguing about whether it was serious, then spent the next decade building half the internet on it.

I came to Node from PHP. The mental model shift was real. Everything I knew about how servers handled requests had to get rebuilt.

What Node.js Actually Is

Node.js is a JavaScript runtime built on Chrome's V8 engine. It lets you run JavaScript outside a browser. You write JavaScript, Node executes it.

That's the surface description. The deeper story is about how Node handles work.

Traditional server architectures assign a thread to each incoming request. The thread handles the request from start to finish: it reads a file, waits for the disk, processes the data, queries a database, waits for the response, formats the result, sends it back. While it waits, the thread blocks. Under load, you spawn more threads, and thread management becomes expensive.

Node takes a different approach. It runs on a single thread and handles waiting through an event loop. When Node starts an I/O operation (reading a file, making a database query, calling an API), it registers a callback and moves on. The event loop picks up the callback when the operation completes. No thread blocks waiting for anything.

The library that makes this possible is libuv, written in C. libuv provides the event loop, the async I/O primitives, and the thread pool Node uses for operations that can't be made async at the OS level (like some file system calls). V8 runs the JavaScript. libuv handles the asynchronous I/O. Together they make Node's concurrency model work.

For a deep understanding of why your async code behaves the way it does, the JavaScript event loop article covers the mechanics in detail. The same model applies to Node.

Getting Started

Node ships with a package manager, npm, and a module system. After downloading Node (use the LTS version for production work), you get the node REPL and the npm command.

A minimal Node application:

// app.js
console.log("Node.js version:", process.version);
console.log("Platform:", process.platform);

Run it with:

node app.js

Building an HTTP Server

Node includes an http module in its standard library. You don't need Express for a basic server.

const http = require("http");

const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url === "/") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", timestamp: Date.now() }));
    return;
  }

  res.writeHead(404);
  res.end("Not Found");
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

This is verbose. In production, you'd use Express, Fastify, or Hono to get routing and middleware without manually handling every URL pattern. But understanding the raw http module shows you what those frameworks sit on top of.

Working with Files

The fs module handles file system operations. It has both callback-based and promise-based APIs.

const fs = require("fs/promises");
const path = require("path");

async function readConfig() {
  try {
    const filePath = path.join(process.cwd(), "config.json");
    const raw = await fs.readFile(filePath, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") {
      throw new Error("config.json not found");
    }
    throw err;
  }
}

Use fs/promises for async file operations. The older callback-based fs API works, but the promise-based version is cleaner and composes better with async/await.

The Module System

Node originally used CommonJS modules. You require() dependencies and module.exports what you want to expose.

// math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }

module.exports = { add, subtract };

// main.js
const { add, subtract } = require("./math");
console.log(add(2, 3)); // 5

ECMAScript Modules (ESM) are the modern standard, using import and export. Node supports ESM with .mjs files or by setting "type": "module" in package.json.

// math.mjs
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// main.mjs
import { add, subtract } from "./math.mjs";
console.log(add(2, 3)); // 5

Mixing CommonJS and ESM in the same project creates complexity. If you're starting a new project, use ESM. If you're maintaining an existing CommonJS project, stay consistent until you have time for a proper migration.

npm and the Package Ecosystem

npm (Node Package Manager) hosts over two million packages. It's the largest package registry for any language. The scope of what's available is genuinely useful and also genuinely chaotic.

npm init -y              # Initialize a new project
npm install express      # Install a dependency
npm install -D typescript # Install a dev dependency
npm run dev              # Run a script defined in package.json

The package.json file defines your project, its dependencies, and the scripts you can run. The package-lock.json locks exact versions for reproducible installs.

One common problem: node_modules folders are large. A project with moderate dependencies can have hundreds of megabytes in node_modules. Bun and pnpm solve this differently; more on that in the What is Bun article.

Node.js in Production

Express

Express is the most widely deployed Node.js web framework. It's minimal: routing, middleware, and not much else. You add what you need.

const express = require("express");
const app = express();

app.use(express.json());

app.get("/users/:id", async (req, res) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "Not found" });
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
  }
});

app.listen(3000);

Express's middleware system (app.use()) lets you stack request processing: authentication, logging, rate limiting, request validation. Each middleware function calls next() to pass control to the next one.

Environment Variables

Never hardcode configuration in source code. Use environment variables.

const config = {
  port: parseInt(process.env.PORT ?? "3000", 10),
  dbUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

if (!config.dbUrl) throw new Error("DATABASE_URL is required");
if (!config.jwtSecret) throw new Error("JWT_SECRET is required");

Validate your environment at startup. Crashing immediately with a clear error is better than failing later with a cryptic one.

Error Handling

Unhandled promise rejections will crash your Node process in modern versions. Catch everything.

// Catch unhandled rejections at the process level (last resort)
process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  process.exit(1);
});

// Better: handle errors in your async route handlers
app.get("/data", async (req, res, next) => {
  try {
    const data = await fetchData();
    res.json(data);
  } catch (err) {
    next(err); // Pass to Express's error handling middleware
  }
});

Performance: The CPU Problem

Node handles concurrent I/O well. It handles CPU-intensive work poorly. If you need to run heavy computation (image processing, encryption, data transformation), blocking the event loop blocks every other request.

Two solutions: Worker Threads for CPU-bound work within Node, or offloading to a separate service.

const { Worker } = require("worker_threads");

function runInWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./heavy-computation.js", {
      workerData: data
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

The worker runs on a separate thread. It doesn't share memory with the main thread directly (you pass data via messages), but it doesn't block the event loop either.

If you want to understand what the garbage collector is doing in a long-running Node application, this article on capturing GC traces covers how to profile it.

TypeScript with Node.js

Node runs JavaScript. To use TypeScript, you compile it first, or use a tool like tsx to run it directly during development.

npm install -D typescript tsx @types/node

A minimal tsconfig.json for a Node project:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

For development: tsx src/index.ts runs the TypeScript directly. For production: tsc && node dist/index.js.

TypeScript and Node.js together is the standard setup for serious backend JavaScript work. The What is TypeScript article covers the type system in depth. The Discord bot guide shows the full setup in a working project.

Node.js vs Its Alternatives

Node.js is thirteen years old. It built the ecosystem — the npm packages, the tooling conventions, the deployment patterns. The tradeoff is that it carries architectural decisions made in 2009.

Ryan Dahl acknowledged this publicly in 2018, listing ten things he regretted about Node.js, and then built Deno to address them. Bun took a different approach, keeping Node compatibility while rebuilding for speed. The Node.js vs Deno vs Bun comparison covers the tradeoffs directly.

When to Use Node.js

Node.js is the right choice when your constraints look like these:

  • Building REST APIs or GraphQL servers with database-backed responses
  • Proxying requests to other services
  • Real-time applications (WebSockets, SSE)
  • CLI tools
  • Server-side rendering for JavaScript frontends
  • Teams that know JavaScript and don't want to introduce a new language

It's the wrong choice when you need sustained CPU work, when memory footprint matters at scale, or when you need the strict guarantees that come from languages like Go or Rust.

The biggest practical argument for Node.js in 2026 is the ecosystem. Two million npm packages. Millions of Stack Overflow answers. If you need a library for something, it exists. That matters when you're shipping.