Node.js Tutorial: From Install to a Running Server
You already know how to run JavaScript: you open a tab, and the browser runs it. Node.js runs the same language somewhere with no tab, no page and no user, and hands it what a server needs instead: files, sockets, processes, environment variables.
Most tutorials describe that much and then start installing packages. That is where they date. A current Node install already watches files, reads .env, runs tests, runs TypeScript and speaks HTTP, and which of those you get depends entirely on the version you installed.
This tutorial goes from picking a version to serving JSON, and names the version floor for every feature it uses.
Node.js is a runtime, not a language
Node.js is not a dialect of JavaScript. It is a runtime: a program that embeds a JavaScript engine and surrounds it with objects the engine does not define.
The engine is V8, the same one in Chrome. It parses your source and executes the language: numbers, strings, closures, classes, promises. It knows nothing about files or ports. Everything outside the language comes from the host environment.
A browser is one host. It supplies window, document and the DOM. Node is another host. It supplies process, Buffer, and a set of core modules: fs for files, net and http for sockets and servers, plus path, os, events and stream.
You can see the swap in four lines:
console.log(typeof window);
console.log(typeof document);
console.log(typeof process);
console.log(typeof fetch);
undefined
undefined
object
function
The first two are missing because there is no page. process is there because there is a process. fetch is a function because Node adopted it as a global, which is the first sighting of this article’s theme: things you used to install now arrive with the runtime.
Core modules are addressed with a node: prefix, as in node:fs/promises, node:path and node:http. The prefix says you mean the built-in rather than a package of the same name in node_modules. Use it everywhere.
Which language features you get is a property of the V8 build inside your binary. Node 24.19.0 bundles V8 13.6; Node 26.7.0 bundles V8 14.6. Underneath V8 sits libuv, the C library Node uses for its event loop, its timers and its thread pool. You never call libuv directly, but its phases decide the order your callbacks run in, which is a whole section below. console.log(process.versions) prints the V8, libuv and OpenSSL versions your binary was built against.
Deno and Bun are different hosts for the same language, and Node.js, Deno & Bun compares them.
Install the right version: LTS vs Current
Node ships two kinds of release line, and picking the wrong kind is the most common way to land on a version that stops receiving fixes.
- Current is the newest line. It gets new features first and is where breaking changes appear.
- Active LTS is a line promoted to long-term support. Breaking changes stop; new features arrive only once they have proved out on Current and the release team has judged them appropriate for the line, which is why an LTS line keeps gaining flags months after it opened.
- Maintenance LTS is the tail of that support: critical fixes only, until the end-of-life date.
The rule that decides everything, through Node 26: even-numbered lines become LTS, odd-numbered lines never do. LTS lines are supported for 30 months in total, and the project’s own guidance is that production applications should only use Active LTS or Maintenance LTS releases. From Node 27 the numbering stops deciding anything: one major release a year every April, every major moving to LTS after its six-month Current phase, and a six-month alpha channel where the odd lines used to be. Node 27 goes alpha in October 2026 and ships in April 2027.
| Line | State in August 2026 | End of life |
|---|---|---|
| 26 | Current since 2026-05-05, scheduled to enter LTS 2026-10-28 | 2029-04-30 |
| 25 | Ended 2026-06-01, never became LTS | 2026-06-01 |
| 24 “Krypton” | Active LTS since 2025-10-28, maintenance from 2026-10-20 | 2028-04-30 |
| 22 “Jod” | Maintenance since 2025-10-21 | 2027-04-30 |
Install Node 24. The latest release on that line is 24.19.0, from 3 August 2026, bundling npm 11.17.0 and V8 13.6. Node 26.7.0 is the Current line as of 5 August 2026, with npm 11.19.0 and V8 14.6; it is worth running locally to see what is coming, and it is not where you deploy until October.
Node 25 is the cautionary example. It opened on 2025-10-15 and reached end of life on 2026-06-01, under eight months later. Anyone who read “install the latest version” in October and shipped it spent June migrating.
Install it. The LTS download on nodejs.org is the whole step, and npm comes inside it; you never install npm separately. Or, with a version manager:
nvm install 24 # or: fnm install 24
nvm use 24 # or: fnm use 24
Check what you have:
node -v
npm -v
The first prints the runtime version, the second prints the npm that came with it. They are different numbers on purpose: 24.19.0 ships npm 11.17.0, and 22.23.2 ships npm 10.9.8.
Installing from nodejs.org gives you one Node. A version manager gives you several and switches between them per project, which is what you want the first time a client repository turns out to need the 22 line. nvm, fnm and Volta are the usual choices.
Your first script: REPL, node app.js, and node —run
Three ways to run code, in the order you meet them.
Type node with no arguments and you get the REPL: a prompt that evaluates one expression at a time and prints the result. It is where you settle an argument about what a method returns without opening an editor. .exit or Ctrl+D leaves.
Put the code in a file and run node app.js:
const guests = 3;
console.log(`table for ${guests}`);
table for 3
The third way needs a package.json, which npm init -y writes for you. Two fields matter immediately. dependencies holds what your code needs when it runs, added by npm install pg. devDependencies holds what only you and CI need, added by npm install -D eslint. Plain npm install installs both, and package-lock.json records the exact versions it resolved.
The scripts field names commands:
{
"name": "rooms-api",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test"
}
}
node --run dev runs that line, without npm in the middle. It was introduced in Node.js 22, and it is deliberately narrower than npm run: it does not run pre or post scripts, it does not define package-manager-specific environment variables, and variables loaded with --env-file are not applied to the command it runs.
Arguments arrive on process.argv, and the first two entries are not yours:
process.argv[0]is the path to the node executable.process.argv[1]is the path to the script being run.process.argv.slice(2)is what the user typed.
Configuration arrives on process.env:
const name = process.env.GREETING_NAME ?? 'world';
console.log(`hello, ${name}`);
hello, world
Set GREETING_NAME in the environment and the branch flips. For a file full of them, node --env-file=.env app.js loads the file before your code runs. The flag was added in v20.6.0, learned multi-line values in v21.7.0 and v20.12.0, and stopped being experimental in v24.10.0 and v22.21.0. Pass --env-file more than once and later files override earlier variables.
Modules: CommonJS, ESM, and which one to use now
Node has two module systems. Tutorials written before about 2023 pick the one you should not start with.
CommonJS is Node’s original system: you pull a module in with require(), which is synchronous, and you expose things by assigning to module.exports. A .js file is CommonJS when the nearest package.json has no type field, and a .cjs extension forces it:
// rooms.cjs, CommonJS, shown here only as the contrast
const rooms = ['garden', 'library', 'attic'];
function find(name) {
return rooms.includes(name);
}
module.exports = { rooms, find };
ES modules are the system the language itself defines: import and export, statically analysed, with top-level await available. A .js file is an ES module when the nearest package.json sets type to module, and a .mjs extension forces it:
// rooms.js
export const rooms = ['garden', 'library', 'attic'];
export function find(name) {
return rooms.includes(name);
}
// server.js
import { find } from './rooms.js';
Write ESM for new code. Every sample after this section uses import and the node: prefix.
Three details bite people on the way over.
The extension is mandatory. In an ES module, ./rooms.js is the specifier and ./rooms is an error. A directory index has to be spelled out in full as ./startup/index.js. CommonJS guessed at both; ESM does not.
__dirname and __filename do not exist. Their replacements do:
import { join } from 'node:path';
const dataFile = join(import.meta.dirname, 'data', 'rooms.json');
import.meta.dirname and import.meta.filename were added in v21.2.0 and v20.11.0 and became stable in v24.0.0 and v22.16.0. They exist only for file: modules, so a module loaded over http has neither.
The third detail is the one that changed the calculus. For years, a CommonJS codebase could not require() an ESM-only package at all, and could only reach one through dynamic import(), which forces the calling function to become async. That is why so much of npm stayed dual-published.
The other direction has always worked. An ES module can import a CommonJS package, taking its module.exports as the default export, and import() returns a promise from either system.
Files and paths: the three fs APIs
Reading a file is where the three shapes of the Node standard library show up at once. Same operation, three APIs.
Promises, which is what you want:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const file = join(import.meta.dirname, 'data', 'rooms.json');
const rooms = JSON.parse(await readFile(file, 'utf8'));
Callbacks, the original API, still exported from node:fs:
import { readFile } from 'node:fs';
readFile(file, 'utf8', (err, text) => {
if (err) throw err; // error first, always argument one
const rooms = JSON.parse(text);
});
Synchronous, which returns the contents and blocks until it has them:
import { readFileSync } from 'node:fs';
const config = JSON.parse(readFileSync(file, 'utf8'));
Use node:fs/promises by default, readFileSync only during startup, and callbacks only when you are editing code that already uses them.
The startup exception is worth being precise about, because it is not a matter of taste. readFileSync stops the entire process while the disk answers. Before your server calls listen(), there is nobody to stop, so reading a config file that way costs nothing and saves you an await. After listen(), the same call freezes every connection the process is holding, including the ones that never asked for a file. Same function, different price, decided by when you call it.
Two smaller notes. Leave the encoding argument off and you get a Buffer, Node’s raw bytes type, rather than a string. And build paths with node:path rather than string concatenation: join inserts the right separator, which is the difference between code that works on Windows and code that does not. basename, extname and resolve come from the same module.
The next section explains why the blocking version is so much worse than it looks.
The event loop, the way Node actually runs it
An event loop is not a queue. It is a rotation, and libuv turns it through six phases in a fixed order:
- timers: callbacks scheduled by
setTimeout()andsetInterval(). - pending callbacks: I/O callbacks deferred to the next loop iteration.
- idle, prepare: used internally only.
- poll: retrieve new I/O events and execute I/O related callbacks. Node blocks here when appropriate, which is what an idle server is doing.
- check:
setImmediate()callbacks are invoked here. - close callbacks: some close callbacks, such as
socket.on('close', ...).
Two more queues cut across all six. Callbacks passed to process.nextTick() run after the current operation finishes, whatever phase the loop is in, and promise callbacks run after those. Run the four schedulers together and the order is fixed:
console.log('1: sync');
setTimeout(() => console.log('5: timer'), 0);
Promise.resolve().then(() => console.log('4: promise'));
process.nextTick(() => console.log('3: nextTick'));
console.log('2: sync');
1: sync
2: sync
3: nextTick
4: promise
5: timer
Both console.log calls run first, because nothing suspends them. Then the nextTick queue drains, then the promise queue, and only then does the loop reach its timers phase and fire a timeout that asked for 0.
None of that explains how readFile can be asynchronous when reading a file blocks everywhere else. It is not the loop being clever. libuv keeps a thread pool, four threads by default, and hands it every file system operation plus the getaddrinfo and getnameinfo DNS lookups. A pool thread does the blocking read; your callback goes back to the loop when it finishes. UV_THREADPOOL_SIZE raises the count up to 1024, a ceiling raised from 128 in libuv 1.30.0. Four is also why sixteen concurrent file reads queue behind four workers rather than all starting at once.
Now the failure mode. Your JavaScript runs on one thread, and the loop cannot turn while that thread is inside your code:
setTimeout(() => console.log('timer wanted 0 ms'), 0);
const start = Date.now();
while (Date.now() - start < 300) {}
console.log('blocked for 300 ms');
blocked for 300 ms
timer wanted 0 ms
The timer was due immediately and ran 300 ms late, because the loop could not reach its timers phase until the while returned. This is a toy on purpose. In a server the same shape is a synchronous hash, a readFileSync after startup, or a JSON.parse of an upload someone made larger than you expected, and while it runs every open connection waits, not only the one that caused it.
Event loop: microtasks and macrotasks works through the queue rules in detail, and One Thread, Many Requests covers what a single loop does with a few thousand connections.
An HTTP server with zero dependencies
Strip the frameworks away and a Node server is one callback that gets a request and a response:
import { createServer } from 'node:http';
const rooms = ['garden', 'library', 'attic'];
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/rooms') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(rooms));
return;
}
if (req.method === 'POST' && url.pathname === '/rooms') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk); // req is a stream
const body = Buffer.concat(chunks).toString('utf8');
try {
rooms.push(JSON.parse(body).name);
} catch {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'invalid json' }));
return;
}
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({ count: rooms.length }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(3000, () => console.log('listening on http://localhost:3000'));
req.url is not the path. Node hands you the raw request target, so a call to /rooms?limit=2 arrives as the string '/rooms?limit=2', query string included. Wrapping it in new URL() with a base gives you pathname and searchParams as separate things, and comparing url.pathname to '/rooms' then does what you meant.
req is a readable stream, which is why the body takes a loop. Nothing collects it for you, and that is the largest single difference between raw node:http and any framework. The try around JSON.parse is not decoration either: without it, a malformed body throws inside an async callback and the client waits for a response that is never coming.
res.writeHead sets the status and headers, and res.end sends the body and closes the response. Every branch ends in exactly one res.end, and forgetting it in one branch is the classic first bug.
A framework earns its place when the routing table stops fitting on one screen. Express, currently 5.2.1 on npm and declaring "node": ">= 18" in its engines field, gives you route matching, middleware ordering and body parsing as conventions instead of as code you wrote yourself at midnight. For a health check, a webhook receiver or a three-route internal service, the file above is the whole application and has no lockfile to audit.
How an HTTP Server Actually Works goes under createServer to the socket underneath.
What Node now ships that you used to install
The starter kit older tutorials install is now mostly flags. That does not make every flag the right answer.
Take the built-in when three things are true: your Node version meets its floor, its stability label reads Stable, and you need only the common case. All three, not two.
| You used to install | Node ships | Version floor | What the rule says |
|---|---|---|---|
| nodemon | node --watch app.js | 16.19 / 18.11 to use, Stable from 20.13 / 22.0 | Built-in from 20.13 or 22.0. |
| dotenv | node --env-file=.env app.js | 20.6 to use, not experimental from 22.21 / 24.10 | Built-in from 22.21 or 24.10. |
| mocha, jest | node --test with node:test | 16.17 / 18.0 to use, Stable from 20.0 | Built-in for plain unit tests. |
| npm run | node --run dev | Node 22 | Built-in for plain scripts, npm run when you need pre or post scripts. |
| ts-node | native type stripping | on by default from 22.18 / 23.6, Stable from 24.12 / 25.2 | Built-in from 24.12 or 25.2, if your types are erasable. |
| node-fetch | global fetch | 18.0 to use, not experimental from 21.0 | Built-in. |
| ws | global WebSocket | 22.0 to use, not experimental from 22.4 | Built-in for client connections, ws for a server. |
| better-sqlite3 | node:sqlite | 22.13 / 23.4 unflagged, release candidate at 25.7 | Keep the package. The label is 1.2, not Stable. |
Where two numbers appear, the change landed on two release lines: the older LTS line at the first number, the newer line at the second. You need whichever applies to the line you are on.
On 24.19.0 every row but the last resolves to the built-in, and the last row is the rule doing its job. node:sqlite works, it exposes a synchronous DatabaseSync API, and its stability label is 1.2, release candidate, as of v25.7.0. That is fine for a migration script and not what goes under a production write path this month.
Native TypeScript is the row with the most fine print. Node strips types rather than compiling them, so it does not read tsconfig.json, it requires the extension in your imports (import './rooms.ts'), it does not generate source maps, and it rejects syntax that needs real transformation: enums, namespaces with runtime code, parameter properties, decorators. --experimental-transform-types was removed in v26.0.0, so that escape hatch is gone, and --no-strip-types turns the feature off. Use any of the rejected syntax and you still want a build step.
The test runner needs no configuration file because it finds tests by name: **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js}, and anything under a test/ directory, plus the same six patterns with .ts, .mts and .cts unless you pass --no-strip-types. The process exits with code 1 if any test fails, which is the entire contract CI needs. Coverage is a different matter: it is still reached through --experimental-test-coverage, so by the rule above it is not the part you build a pipeline on yet. Testing: Vitest & Playwright covers what the built-in runner does not try to do.
Reading labels is the habit worth forming, because they are not uniform. URLPattern became a global in v24.0.0 and is still marked Stability 1, Experimental. The label sits at the top of every page of the API docs, and it is the cheapest check in Node.
One built-in replaces no package at all. Run with --permission and the process starts restricted, and you grant access explicitly with flags including --allow-fs-read, --allow-fs-write, --allow-net, --allow-child-process, --allow-worker and --allow-addons. At runtime, process.permission.has() asks what you were given and process.permission.drop() gives some of it up. It was added in v20.0.0, stopped being experimental in v23.5.0 and v22.13.0, and is Stability 2, Stable, which by the rule makes it available to you today on Node 24.
From here, the server side keeps going: streams as a first-class API, worker threads, clustering across cores, and shipping the result, which Deploying a JavaScript App takes up. The same ground in order, offline and yours to keep, is Part 7: Server-Side JavaScript.
Frequently asked questions
Which version of Node.js should I install?
Should I use CommonJS or ES modules in Node.js?
type field set to module in package.json, then use import and export. CommonJS is still fully supported and is what a .js file means when package.json has no type field. Since v23.0.0, v22.12.0 and v20.19.0 you can also require() an ES module without a flag, but only if that module is fully synchronous.What replaces __dirname in an ES module?
import.meta.dirname gives the directory of the current module and import.meta.filename gives its full path. Both were added in v21.2.0 and v20.11.0 and became stable in v24.0.0 and v22.16.0, and they exist only for file: modules. ES modules also require the extension in relative specifiers, so './rooms.js' rather than './rooms'.Do I still need nodemon and dotenv?
node --watch app.js restarts the process on file changes and is marked Stable, and node --env-file=.env app.js loads environment variables and stopped being experimental in v22.21.0 and v24.10.0. node --test covers plain unit tests as well. Check the version floor against your own install before deleting the packages.