Node.js Introduction

Node.js is a JavaScript runtime built on Google Chrome’s V8 engine that lets you run JavaScript outside a web browser — on a server, in a command-line tool, or as part of a build pipeline. Instead of a browser tab providing the DOM, the window, and user events, Node.js provides its own set of APIs: a filesystem module, networking modules, a process object, and a module system for organizing code. It matters because it lets you use one language, JavaScript, across both the browser and the server, and because its design — a single-threaded event loop with non-blocking I/O — makes it unusually efficient at handling many simultaneous network requests. This lesson covers what Node.js actually is, how it works internally, and how to write and run your first real programs with it.

What Node.js Is and How It Works

Node.js embeds Google’s V8 engine, the same JavaScript engine that powers Chrome, to parse and execute your JavaScript. On top of V8, Node adds a large set of built-in (“core”) modules — fs for the filesystem, http for networking, path, process, events, stream, and more — and a require/module.exports system for splitting code into files. It also removes everything that only makes sense inside a browser: there is no window, no document, and no DOM in Node.js.

The piece that makes Node.js distinctive is libuv, a C library that gives Node its event loop and asynchronous I/O. Your JavaScript itself runs on a single main thread — there is no automatic parallelism for your code. What Node does instead is hand off slow operations (reading a file, querying a database, making an HTTP request, waiting for a timer) to the operating system or to a small pool of background threads (the libuv thread pool, four threads by default, adjustable with the UV_THREADPOOL_SIZE environment variable), and keep the main thread free to keep running JavaScript. When an operation finishes, libuv queues its callback, and the event loop picks it up and runs it on the main thread as soon as the thread is free. This is the non-blocking I/O model: instead of a thread sitting idle while it waits on a disk or a network socket, Node lets that thread process other work, and comes back to your callback only once the result is ready.

This is why Node.js is so effective for I/O-heavy workloads like web servers and APIs: a single Node process can juggle thousands of open connections, because most of the waiting happens off the main thread. The trade-off is that any synchronous, CPU-bound code you write — a big loop, a synchronous file read, an expensive computation — blocks that single thread completely. While it runs, nothing else can happen: no other request is served, no timer fires, no I/O callback runs. Keeping that main thread free is the central discipline of writing good Node.js code, and it is why the built-in APIs so often come in blocking and non-blocking flavors (for example fs.readFileSync versus fs.readFile/fs.promises.readFile).

Node.js also ships bundled with npm (Node Package Manager), which gives you access to the largest package registry in the JavaScript ecosystem and a package.json file that records your project’s metadata, dependencies, and scripts. Almost everything you build in Node, from a small script to a production API, will use npm to pull in and manage dependencies.

Syntax: Installing and Running Node.js

Once Node.js is installed, you interact with it mainly through the node and npm commands on the command line:

Command What it does
node app.js Runs a JavaScript file with Node.js
node Starts the interactive REPL (Read-Eval-Print Loop) for trying out JavaScript line by line
node --version / node -v Prints the installed Node.js version
node --check app.js Parses a file to check for syntax errors, without running it
npm init -y Creates a package.json file using npm’s default answers
npm install <package> Downloads a package into node_modules and records it as a dependency in package.json
npm start Runs the "start" script defined in package.json

A freshly created package.json from npm init -y looks like this:

{
  "name": "my-first-node-app",
  "version": "1.0.0",
  "main": "hello.js",
  "scripts": {
    "start": "node hello.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "license": "ISC"
}

Notice that Node.js core modules are imported by name, ideally with the node: prefix — for example require("node:fs") rather than plain require("fs"). Both work for most core modules, but the node: prefix is the current recommended style: it makes it immediately clear, just by reading the import, that a module is a built-in and not something installed from npm. This lesson uses Node’s traditional CommonJS module system (require/module.exports); a later lesson covers ES Modules (import/export) in Node.js specifically.

Examples

Example 1: Your First Node.js Script

Save this as hello.js and run it with node hello.js:

// hello.js
console.log("Hello from Node.js!");
console.log("Node.js version:", process.version);
console.log("Platform:", process.platform);
console.log("Script arguments:", process.argv.slice(2));
console.log("Current directory:", __dirname);

Output:

Hello from Node.js!
Node.js version: v22.11.0
Platform: linux
Script arguments: []
Current directory: /home/you/my-first-node-app

The exact version, platform, and directory will differ on your machine — that is the point of process. It is a global object, available in every Node.js file without requiring anything, that describes the currently running Node process: its version, the operating system it’s on, the command-line arguments it was started with (process.argv), and much more. __dirname is a variable that CommonJS modules receive automatically, holding the absolute path of the folder containing the current file — useful for building file paths that work no matter where the script is run from.

Example 2: A Minimal HTTP Server

This is the example that usually convinces people Node.js is interesting: a working web server in a dozen lines, with no framework required.

// server.js
const http = require("node:http");

const server = http.createServer((req, res) => {
  if (req.url === "/") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Welcome to my first Node.js server!\n");
  } else {
    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end("Not Found\n");
  }
});

const PORT = process.env.PORT ?? 3000;

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

Output:

$ node server.js
Server running at http://localhost:3000/

$ curl http://localhost:3000/
Welcome to my first Node.js server!

http.createServer takes a callback that Node invokes every time a new HTTP request arrives — this is the event-driven model in action: you register a handler once, and the event loop calls it whenever the matching event (an incoming request) occurs. Inside the handler, res.writeHead sets the status code and headers, and res.end sends the body and finishes the response; every code path through your handler must eventually call res.end(), or the client’s connection will hang. server.listen(PORT, callback) binds the server to a port and runs its callback once the server is actually accepting connections. Reading the port from process.env.PORT (falling back to 3000 with ??) is a common pattern so the same code works locally and in hosting environments that assign a port dynamically.

Example 3: Synchronous Code vs. Queued Callbacks

This example does not do anything useful on its own, but it is the clearest way to see the event loop’s ordering rules in action.

// order.js
const fs = require("node:fs");

console.log("1: Start of script");

setTimeout(() => {
  console.log("4: setTimeout callback (timers phase)");
}, 0);

fs.readFile(__filename, () => {
  console.log("5: fs.readFile callback (poll phase)");
});

Promise.resolve().then(() => {
  console.log("3: Promise microtask");
});

process.nextTick(() => {
  console.log("2: process.nextTick callback");
});

console.log("1b: End of synchronous code");

Output:

1: Start of script
1b: End of synchronous code
2: process.nextTick callback
3: Promise microtask
4: setTimeout callback (timers phase)
5: fs.readFile callback (poll phase)

Even though setTimeout is written before fs.readFile, and both are written before the Promise and process.nextTick calls, none of their callbacks run until after every line of synchronous code has finished. The next section walks through exactly why they come back in this order.

How It Works Step by Step (Under the Hood)

Follow Example 3 through Node’s execution model:

  1. Node runs the file top to bottom as ordinary synchronous JavaScript, on the single main thread. "1: Start of script" prints immediately.
  2. setTimeout(callback, 0) does not run the callback now — it registers it with libuv’s timer system with a minimum delay (effectively ~1ms even when you pass 0), and execution continues to the next line.
  3. fs.readFile is asynchronous I/O. Node asks libuv to read the file; libuv performs that read using the OS’s async file APIs or a thread-pool thread, and registers a callback to run once the read completes. Execution continues immediately — it does not wait for the disk.
  4. Promise.resolve().then(...) schedules its callback onto the microtask queue.
  5. process.nextTick(...) schedules its callback onto the separate nextTick queue, which Node always drains before the microtask queue.
  6. "1b: End of synchronous code" prints, and the call stack is now empty — the synchronous part of the script is done.
  7. Before moving to any event loop phase, Node drains the nextTick queue completely: "2: process.nextTick callback" prints.
  8. Node then drains the microtask queue: "3: Promise microtask" prints. (Node checks both of these queues again after every single callback it runs, not just once at the start — but with only one callback in each queue here, they simply run in this order.)
  9. The event loop now proceeds through its phases. The timers phase runs any timer whose delay has elapsed, so the setTimeout callback fires: "4: setTimeout callback" prints.
  10. The loop continues to the poll phase, where completed I/O callbacks are invoked. By now the file read has typically finished, so the fs.readFile callback fires: "5: fs.readFile callback" prints.

The full phase order in one pass of the loop is: timers → pending callbacks → poll → check (setImmediate) → close callbacks, with the nextTick queue and microtask queue drained in between every phase and every individual callback. The practical lesson is this: process.nextTick and promise callbacks always run before the next timer or I/O callback, no matter how they’re interleaved in your source code, and nothing on any of these queues runs until the currently executing synchronous code finishes completely.

Common Mistakes

Mistake 1: Blocking the event loop inside a request handler

const http = require("node:http");
const fs = require("node:fs");

const server = http.createServer((req, res) => {
  // Blocks the ENTIRE process for every request while the file is read from disk
  const data = fs.readFileSync("./big-report.csv");
  res.writeHead(200);
  res.end(data);
});

server.listen(3000);

Why it’s wrong: fs.readFileSync blocks the single JavaScript thread until the entire file has been read. While that read is happening, the event loop cannot process any other request, timer, or callback — every other connected client freezes, even if their requests have nothing to do with this file. A synchronous call like this is fine during one-time startup work (reading a config file before the server starts), but it is one of the most common causes of a Node.js server that “randomly” becomes unresponsive under load.

Corrected, using the non-blocking promise-based API:

const server = http.createServer(async (req, res) => {
  try {
    const data = await fs.promises.readFile("./big-report.csv");
    res.writeHead(200);
    res.end(data);
  } catch (err) {
    res.writeHead(500);
    res.end("Failed to read file");
  }
});

Mistake 2: Assuming Node.js has browser globals

document.getElementById("output").textContent = "Hello";

Why it’s wrong: document and window are DOM globals provided by a web browser. Node.js has no DOM and no browser globals; running this throws ReferenceError: document is not defined. Node’s global object is called global, and the things you actually have available are things like console, process, and the core modules — not anything related to rendering HTML.

The Node.js way to produce output is simply:

console.log("Hello");

Mistake 3: Ignoring the error argument in a callback

fs.readFile("./config.json", (err, data) => {
  const config = JSON.parse(data);
  console.log(config.port);
});

Why it’s wrong: Node’s callback-style APIs follow an “error-first” convention — the first argument is either an Error or null. This code ignores err entirely. If the file is missing or unreadable, data will be undefined, and JSON.parse(undefined) throws an unrelated-looking exception instead of a clear, handled error message pointing at the real problem.

Corrected:

fs.readFile("./config.json", (err, data) => {
  if (err) {
    console.error("Could not read config:", err.message);
    return;
  }
  const config = JSON.parse(data);
  console.log(config.port);
});

Best Practices

  • Target a current LTS release (Node 20.x or 22.x) for anything you deploy, and record it in your package.json "engines" field so teammates and hosts know what’s required.
  • Prefer node:-prefixed imports for core modules (require("node:fs")) so it’s obvious at a glance that a module is built-in rather than an npm package.
  • Keep synchronous, CPU-heavy work out of request handlers; if you must do something slow and CPU-bound, move it to a worker_threads worker or a separate service/queue.
  • Always check the error parameter in callbacks, and always wrap awaited calls in try/catch — never let a rejected promise go unhandled.
  • Use npm scripts (the "scripts" section of package.json) to standardize how a project is started and tested, instead of memorizing raw node commands.
  • Commit a package.json and let teammates run npm install, rather than relying on globally installed packages that make the project non-reproducible.
  • Use the built-in REPL (just run node) or node --check file.js to quickly try snippets or catch syntax errors before running a full program.

Practice Exercises

  1. Create info.js that prints your Node.js version, the operating system platform, and the number of command-line arguments passed to the script. Run it with node info.js foo bar and confirm process.argv.slice(2) shows ["foo", "bar"].
  2. Extend the HTTP server from Example 2 with a new route, /time, that responds with the current server time as JSON (hint: use res.writeHead(200, { "Content-Type": "application/json" }) and JSON.stringify).
  3. Write a script with two setTimeout calls (one with a 0ms delay, one with 50ms), one process.nextTick, and one Promise.resolve().then(...). Before running it, write down the order you expect the five console.log lines (including the synchronous ones) to print, then run it with node and check your prediction against the rules from the “How It Works Step by Step” section.

Summary

  • Node.js is a JavaScript runtime built on V8 that runs JavaScript outside the browser, adding server-side APIs like fs, http, and process instead of DOM globals like window and document.
  • Your JavaScript runs on a single main thread; libuv provides the event loop and delegates slow I/O to the OS or a background thread pool so the main thread stays free.
  • This non-blocking model is what lets one Node.js process handle many concurrent connections efficiently — but any blocking synchronous code you write stalls everything else.
  • Execution order is: synchronous code first, then the process.nextTick queue, then promise microtasks, then the event loop’s phases (timers, then poll for I/O, then check/setImmediate, then close callbacks).
  • npm ships with Node.js and manages your project’s dependencies and scripts through package.json.
  • Always use the non-blocking (promise or callback) API flavor inside request handlers, always check callback errors, and remember Node has no DOM.