What Node.js Actually Is

Node.js is Chrome’s V8 JavaScript engine, taken out of the browser and given access to your operating system. That is genuinely the whole idea. V8 compiles and runs JavaScript; Node wraps it with libraries for the filesystem, networking, processes and cryptography — the things a browser deliberately keeps away from web pages.

It is not a language and not a framework. It is a runtime, in the same sense that the PHP or Python interpreter is a runtime.

Browser                          Node.js
-------                          -------
V8 engine                        V8 engine
DOM, window, document            fs, http, path, os, crypto
fetch, localStorage              process, Buffer, child_process
Sandboxed from the OS            Full access to the machine

The second thing Node brought was a concurrency model. It handles thousands of simultaneous connections on a single thread by never blocking on I/O — instead of one thread per request waiting on the database, one thread juggles all of them and picks each up again when its data arrives.

How It Differs From Browser JavaScript

The language is identical. The environment is not, and these are the differences that trip people up on day one:

BrowserNode.js
window is the global objectglobalThis / global
document, DOM APIsNone — there is no page
localStorageFiles, or a real database
Scripts loaded by <script>import / require()
CORS restrictionsNone — you are the server
One user per pageEvery user shares one process

That last row matters more than it looks. In the browser, a global variable belongs to one user. In Node, a module-level variable is shared by every request the process handles. Storing a “current user” in a module variable is a data-leak bug that only appears under concurrent traffic.

Installing Node Properly

Do not install Node from the OS package manager and do not install it globally as root. Use a version manager — different projects will need different versions, and you will need to switch.

# macOS / Linux — nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

nvm install --lts        # latest long-term-support release
nvm install 22
nvm use 22
nvm alias default 22

# Windows — nvm-windows, or use fnm / volta on any platform
node --version    # v22.x.x
npm --version     # 10.x.x

Commit a .nvmrc file containing just the version number (22). Anyone cloning the repo runs nvm use and gets the right runtime, and CI can read the same file. Use LTS releases in production — even-numbered majors, supported for 30 months.

Your First Script

// hello.js
const name = process.argv[2] ?? 'world';

console.log(`Hello, ${name}!`);
console.log('Running on Node', process.version);
console.log('Working directory:', process.cwd());
console.log('Platform:', process.platform);
node hello.js
node hello.js Jaydeep

process is your window onto the outside world: command-line arguments, environment variables, the current directory, stdin and stdout, and the exit code.

process.argv;          // ['node', '/path/to/hello.js', 'Jaydeep']
process.env.NODE_ENV;  // environment variables
process.exit(1);       // exit with a failure code
process.on('SIGINT', () => { console.log('Shutting down'); process.exit(0); });

npm and package.json

package.json is the manifest for your project — its name, its scripts, and every dependency it needs.

npm init -y                       # create package.json
npm install express               # add a runtime dependency
npm install --save-dev nodemon    # add a development-only dependency
npm install                       # install everything listed
npm ci                            # install exactly what the lockfile says (use in CI)
{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.0.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

Run scripts with npm run dev (start and test work without run). Modern Node has a built-in watcher, so node --watch replaces nodemon for most projects.

Two things about versions and the lockfile:

  • ^5.0.0 means “5.x.x” — minor and patch updates are allowed. ~5.0.0 allows patch only. A bare 5.0.0 pins exactly.
  • Commit package-lock.json. It records the exact resolved version of every transitive dependency, which is what makes a build reproducible. npm ci installs from it and fails if it disagrees with package.json.

Never commit node_modules/. Add it to .gitignore on the first commit — it is thousands of files, it is platform-specific, and the lockfile already describes it exactly.

Building an HTTP Server

Node ships with an HTTP server. No framework required:

// server.js
import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
  }

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end('<h1>Hello from Node</h1>');
});

const port = process.env.PORT || 3000;
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
node --watch server.js

That is a real web server in twelve lines. In practice you will reach for Express, Fastify or Hono almost immediately — routing, body parsing and error handling get tedious fast — but it is worth seeing the layer underneath once.

Read the port from process.env.PORT, not a hard-coded number. Every hosting platform — Render, Railway, Fly, Heroku, App Engine — assigns the port at runtime and expects your app to use it.

Working With Files

Use the promise-based API. The callback version still exists and you will meet it in older code, but there is no reason to write new code with it.

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

// Read
const raw = await readFile('data.json', 'utf8');
const data = JSON.parse(raw);

// Write
await writeFile('out.json', JSON.stringify(data, null, 2), 'utf8');

// Directories
await mkdir('exports', { recursive: true });
const files = await readdir('exports');

// Build paths with path.join — never string concatenation
const target = path.join(process.cwd(), 'exports', 'report.csv');

For anything large, use a stream. Reading a 2 GB file with readFile tries to hold 2 GB in memory; a stream processes it in chunks:

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

await pipeline(
  createReadStream('huge.log'),
  createGzip(),
  createWriteStream('huge.log.gz'),
);

path.join() uses the right separator for the platform. Hard-coding 'exports/' + name works on your Mac and breaks on a Windows machine — and joining unvalidated user input into a path is how directory-traversal bugs happen. Validate the filename, then join.

Environment Variables and Config

Secrets and per-environment settings belong in the environment, never in the repository.

# .env  (git-ignored)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://localhost:5432/myapp
JWT_SECRET=change-me
# Node 20.6+ loads it natively — no dotenv package needed
node --env-file=.env server.js
// config.js — validate once, at boot, and fail loudly
const required = ['DATABASE_URL', 'JWT_SECRET'];

for (const key of required) {
  if (!process.env[key]) {
    console.error(`Missing required env var: ${key}`);
    process.exit(1);
  }
}

export default {
  env:  process.env.NODE_ENV ?? 'development',
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret:   process.env.JWT_SECRET,
  isProd: process.env.NODE_ENV === 'production',
};

Crashing at startup on a missing variable is much better than discovering it at 2am when the first request that needs it arrives. Commit a .env.example with the keys and no values so new developers know what to fill in.

What Node Is Good At (and Not)

Great fitPoor fit
REST and GraphQL APIsHeavy CPU work — video encoding, large image processing
Real-time apps — chat, notifications, live dashboardsLong synchronous number crunching
Gateways that aggregate other servicesAnything needing true shared-memory threading
Build tooling and CLIsWorkloads better served by a typed compiled language
Server-rendered JavaScript apps (Next, Nuxt, Remix)

The rule follows from the single thread: Node excels when the work is waiting (on a database, a disk, another API) and struggles when the work is computing. A tight loop that runs for 500 ms blocks every other request for 500 ms.

// This blocks EVERY concurrent request for the duration
app.get('/report', (req, res) => {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) total += i;   // ~seconds
  res.json({ total });
});

When you genuinely need CPU work, move it off the main thread with a worker_thread, a child process, or a queue consumed by a separate service.

Where to Go Next

  • Learn the event loop properly. It explains almost every surprising Node behaviour — there is a dedicated guide linked below.
  • Pick a framework. Express is the safe default with the largest ecosystem; Fastify is faster with built-in validation; Hono is excellent for edge runtimes.
  • Understand modules. The CommonJS versus ES modules split causes more beginner errors than anything else in Node.
  • Add TypeScript early. Node 22+ can strip types natively, and on a server the type safety pays for itself quickly.
  • Use the built-in test runner. node --test needs no dependencies at all.
// test/math.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';

test('adds numbers', () => {
  assert.equal(2 + 2, 4);
});

You already know the language. Node is mostly about learning what the runtime hands you and how not to block the one thread you have.