Node.js Fundamentals
What is Node.js
Node.js is a JavaScript runtime built on Chrome's V8 engine. It lets you run JavaScript outside the browser — on servers, in CLI tools, in build pipelines. Unlike browser JS, Node.js has access to the file system, network sockets, operating system APIs, and child processes.
Key characteristics:
- Single-threaded event loop — one thread handles all requests. I/O operations (file reads, network calls, database queries) are non-blocking: Node starts the operation, continues processing other requests, and runs a callback when the result is ready. This makes it efficient for I/O-heavy workloads (web servers, APIs) but unsuitable for CPU-heavy computation (image processing, machine learning).
- V8 engine — same JavaScript engine as Chrome. Compiles JS to machine code at runtime (JIT compilation).
- npm ecosystem — the largest package registry in any language.
# Check your Node.js version (20+ required)
node --version
# Run a script
node script.js
# Start a REPL
node
Node.js 20+ is required — we use import.meta.dirname (available since Node 20.11) and Express 5. Use the latest LTS version (even-numbered: 20, 22).
CommonJS vs ES Modules
Node.js supports two module systems. You need to know both — many npm packages still use CommonJS, while modern code uses ES modules.
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| File extension | .js (default) or .cjs | .mjs or .js (with "type": "module") |
Top-level await | No | Yes |
__dirname | Available | Use import.meta.dirname (Node 20.11+) |
this at top level | module.exports | undefined |
CommonJS
// math.js — CommonJS
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js
const { add } = require("./math");
console.log(add(2, 3)); // 5
ES Modules
To use ESM, add "type": "module" to package.json:
{
"type": "module"
}
// math.ts — ES Module
export function add(a: number, b: number): number {
return a + b;
}
// app.ts
import { add } from "./math.js"; // Note: .js extension even for .ts files
console.log(add(2, 3)); // 5
Always use .js extensions in imports when using ES modules with TypeScript (e.g., import { add } from "./math.js"), even though the source file is .ts. TypeScript does not rewrite import paths — the compiled output expects .js files at runtime. Omitting the extension or using .ts causes ERR_MODULE_NOT_FOUND at runtime.
We use ES modules throughout this course. Your tsconfig.json should target "module": "NodeNext" and your package.json should include "type": "module".
File System
Node.js provides the fs module for file system operations. Use the promise-based API (fs/promises) with async/await:
import fs from "fs/promises";
// Read a file
const content = await fs.readFile("data.json", "utf-8");
const data = JSON.parse(content);
// Write a file
await fs.writeFile("output.json", JSON.stringify(data, null, 2));
// Check if a file exists
try {
await fs.access("config.json");
console.log("File exists");
} catch {
console.log("File does not exist");
}
// Read directory contents
const files = await fs.readdir("./src");
console.log(files); // ["index.ts", "app.ts", ...]
// Create a directory (recursive: true creates parent dirs)
await fs.mkdir("dist/db", { recursive: true });
// Delete a file
await fs.unlink("temp.txt");
The synchronous API (fs.readFileSync, etc.) is available for scripts and initialization code where blocking is acceptable:
import fs from "fs";
// Synchronous — blocks the event loop until done
const content = fs.readFileSync("config.json", "utf-8");
Never use synchronous fs operations in request handlers. fs.readFileSync blocks the entire event loop — while one request reads a file, all other requests wait. Use fs/promises with await in any code that runs during request processing. Synchronous reads are fine during server startup (e.g., reading config or migration files before app.listen()).
Path Module
The path module handles file paths correctly across operating systems (forward slashes on Linux/Mac, backslashes on Windows):
import path from "path";
// Join path segments
path.join("src", "db", "migrations"); // "src/db/migrations"
// Resolve to absolute path
path.resolve("src", "index.ts"); // "/Users/you/project/src/index.ts"
// Get parts of a path
path.dirname("/src/db/database.ts"); // "/src/db"
path.basename("/src/db/database.ts"); // "database.ts"
path.extname("/src/db/database.ts"); // ".ts"
__dirname in ES Modules
In CommonJS, __dirname gives the directory of the current file. In ES modules, it does not exist. Use import.meta.dirname (Node.js 20.11+) or the import.meta.url fallback:
// Node.js 20.11+ (recommended)
const migrationsDir = path.join(import.meta.dirname, "migrations");
// Fallback for older Node.js versions
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
Environment Variables
Environment variables configure your application without hardcoding values. They are read from process.env:
const port = process.env.PORT || 3000;
const dbPath = process.env.DB_PATH || "app.db";
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error("JWT_SECRET environment variable is required");
}
.env files with dotenv
In development, store variables in a .env file. The dotenv package loads them into process.env:
npm install dotenv
# .env — never commit this file to git
PORT=3000
DB_PATH=task-api.db
JWT_SECRET=your-secret-key-at-least-32-chars-long
CORS_ORIGIN=http://localhost:5173
// In your entry point file (e.g., index.ts), use the side-effect import:
import "dotenv/config"; // loads .env into process.env before other modules
console.log(process.env.PORT); // "3000"
In ESM, import declarations are hoisted — all imported modules are evaluated before the importing module's body runs. import "dotenv/config" works because it is a side-effect import that executes dotenv.config() during module evaluation. Writing dotenv.config() in the module body would be too late — other imported modules that read process.env would already be loaded with missing values.
Never commit .env to git. Add it to .gitignore. Leaked secrets (database passwords, JWT keys, API tokens) in git history are a common security incident. Commit a .env.example instead with placeholder values to document which variables are needed.
Starter .gitignore:
node_modules
dist
*.db
.env
Environment in production
In production, environment variables are set by the deployment platform — Docker Compose, Kubernetes, CI/CD pipelines — not by .env files:
# docker-compose.yml
services:
api:
environment:
- JWT_SECRET=${JWT_SECRET}
- DB_PATH=/data/app.db
# Or passed directly
JWT_SECRET=prod-secret PORT=8080 node dist/index.js
npm Scripts
The scripts field in package.json defines commands for your project:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest",
"test:run": "vitest run",
"lint": "tsc --noEmit"
}
}
Run them with npm run:
npm run dev # start dev server with hot reload
npm run build # compile TypeScript to JavaScript
npm run start # run compiled output (production)
npm test # shorthand for "npm run test"
npm run lint # type-check without emitting files
npm test and npm start are special — they don't need run. All other scripts require npm run <name>.
npx
npx runs commands from locally installed packages without global installation:
npx vitest run # runs vitest from node_modules/.bin
npx tsc --noEmit # runs typescript compiler
npx create-next-app # downloads and runs a one-off command
package.json Essentials
{
"name": "task-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^5.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"tsx": "^4.0.0",
"@types/node": "^22.0.0"
}
}
Key fields:
"type": "module"— enables ES module syntax (import/export)dependencies— required at runtime, installed withnpm installdevDependencies— only needed during development/build, skipped withnpm install --omit=dev
npm install vs npm ci
| Command | When to use |
|---|---|
npm install | Development — installs deps, updates package-lock.json |
npm ci | CI/Docker — clean install from lock file, faster, reproducible |
Always use npm ci in Dockerfiles and CI pipelines. It installs exactly what's in package-lock.json without modifying it.
Defence Preparation
Be prepared to explain topics like these:
-
What is the Node.js event loop, and why is Node single-threaded? — Node.js uses a single thread for JavaScript execution but offloads I/O operations (file reads, network requests, database queries) to the operating system or a thread pool. When an I/O operation completes, its callback is placed in the event loop queue. The event loop continuously picks up these callbacks and runs them. This design avoids the complexity of multi-threaded programming (locks, deadlocks, race conditions) while handling thousands of concurrent connections efficiently — each connection is just a callback, not a thread.
-
What is the difference between CommonJS and ES Modules? — CommonJS uses
require()andmodule.exports, loads synchronously, and is the original Node.js module system. ES Modules useimport/export, load asynchronously, support top-levelawait, and are the JavaScript standard (also used in browsers). To use ESM in Node.js, add"type": "module"topackage.json. When using TypeScript with ESM, import paths must end in.jseven for.tssource files. -
Why should you never use synchronous
fsoperations in request handlers? — Synchronous operations likefs.readFileSync()block the event loop. While one request waits for a file read to complete, no other request can be processed — the server is effectively frozen. Withfs/promisesandawait, the event loop continues processing other requests while the file I/O happens in the background. Synchronous reads are acceptable only during server startup (beforeapp.listen()), when there are no concurrent requests to block. -
Why should
.envfiles never be committed to git? —.envfiles contain secrets: database passwords, JWT signing keys, API tokens. If committed, these secrets are in the git history forever — even if you delete the file later, anyone with repo access can find them. Use.gitignoreto exclude.envand commit a.env.examplewith placeholder values instead. In production, environment variables are set by the deployment platform (Docker, CI/CD), not by files. -
What is the difference between
npm installandnpm ci? —npm installresolves dependencies, potentially updatespackage-lock.json, and is meant for development.npm ciperforms a clean install from the exact versions inpackage-lock.json, deletesnode_modulesfirst, and never modifies the lock file.npm ciis faster and deterministic — use it in Dockerfiles and CI to ensure everyone gets exactly the same dependency tree.