<< Home

Chapter 19: Node.js Essentials

Node.js lets you run JavaScript outside the browser on your own machine and on servers. You use the language you already know, with a fast event loop, a useful standard library, and a very large package ecosystem.

In this chapter you will learn how to run scripts, load modules, and manage dependencies. You will be able to read files with fs, build safe paths with path, and work with the current process through process, environment variables, and command line arguments in process.argv. You will also learn to send and receive HTTP requests and will see a simple client and a small server with http.createServer, and you will learn where the modern fetch API fits in Node.js.

Finally you will be able to set up a project with package.json. You will install packages with npm, choose versions with semantic version ranges, and create useful run targets in scripts. The goal is portable and predictable code that is easy to share.

💡 Use a current Long Term Support release of Node.js. Check your tools with node -v and npm -v before you begin.

Node.js supports two module systems. CommonJS uses require and module.exports. ECMAScript modules use import and export. Each file chooses one system. Opt into ECMAScript modules with "type": "module" in package.json or by using the .mjs extension. Top level await works only in ECMAScript modules.

⚠️ Paths differ by platform. Prefer path.join and URL helpers over manual string building. When you see braces with unknown content, such as {"name": "…"}, treat it as a placeholder and keep the structure unchanged.

By the end you will be comfortable writing small utilities, simple servers, and repeatable workflows in Node.js. You will know how to run code, use modules, make network calls, read from the filesystem, and manage packages with confidence.

Running scripts and using modules

Node.js runs JavaScript directly from files on your system. Any file with a .js or .mjs extension can be executed from the command line. This makes it simple to test snippets, build utilities, or run full programs without a browser.

To run a file, type:

node app.js

The Node.js runtime reads the file, compiles it to machine code using the V8 engine, and executes it. Any calls to console.log() or other I/O functions appear in your terminal.

💡 You can run single lines of code interactively with node alone. This opens the Read-Eval-Print Loop (REPL), useful for quick testing and inspection.

When scripts grow beyond a few lines, it makes sense to split them into modules. Node.js supports both CommonJS and ECMAScript module systems. CommonJS is older and still common in many projects, while ECMAScript modules follow the modern JavaScript standard.

CommonJS modules

In a CommonJS module, you export values using module.exports and import them with require():

// math.js
module.exports.add = function(a, b) {
  return a + b;
};

// app.js
const math = require('./math.js');
console.log(math.add(2, 3));

The require() function loads a file once and caches its exports, so repeated imports are fast and consistent across a program.

ECMAScript modules

ECMAScript modules (ESM) use import and export statements. To enable ESM, either name your files with a .mjs extension or set "type": "module" in your package.json file.

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

// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));
⚠️ CommonJS and ECMAScript modules cannot be freely mixed in the same file. If you must import one from the other, use dynamic import() or compatibility loaders.

Both systems isolate code by file, provide local scope, and prevent variable collisions. They also simplify testing and maintenance by allowing small, focused pieces of functionality to be developed independently.

Later in this chapter, you will see how modules combine with npm packages to form reusable components across many projects.

Filesystem and path basics

Node.js provides built-in modules for working with the filesystem and file paths. The most important are fs for reading and writing files, and path for building and manipulating file paths in a platform-independent way.

Both modules are part of the Node.js standard library, so no installation is required. You can import them directly:

import fs from 'fs';
import path from 'path';

In older CommonJS code, you would use require() instead:

const fs = require('fs');
const path = require('path');

Reading and writing files

Use fs.readFile() and fs.writeFile() to work with file content asynchronously. Each function takes a path, optional encoding, and a callback. Promise-based variants are also available through fs.promises.

import { readFile, writeFile } from 'fs/promises';

const input = await readFile('input.txt', 'utf8');
await writeFile('output.txt', input.toUpperCase());
console.log('File written successfully');
💡 Prefer the fs/promises API for modern code. It avoids nested callbacks and integrates cleanly with async and await.

When you only need quick synchronous access, such as reading configuration files at startup, the synchronous forms (fs.readFileSync() and fs.writeFileSync()) are available but block execution until complete.

Working with directories

You can inspect and manipulate directories with fs.readdir(), fs.mkdir(), and related functions. For example:

import { readdir, mkdir } from 'fs/promises';

await mkdir('logs', { recursive: true });
const files = await readdir('logs');
console.log(files);

The recursive option ensures that all required parent directories are created if they do not yet exist.

Building safe paths

File paths differ between operating systems, using either forward slashes (/) or backslashes (\). To avoid errors, always use the path module to join directories and filenames safely:

import path from 'path';

const logPath = path.join('logs', 'server.log');
console.log(logPath);  // Works on all platforms

Other useful helpers include path.basename(), path.dirname(), and path.extname(), which extract parts of a file path. You can also use path.resolve() to compute absolute paths relative to the current working directory.

⚠️ Avoid building file paths manually with string concatenation. Use path.join() or new URL() to stay portable and safe across systems.

When using ECMAScript modules, __dirname and __filename are not defined. To reproduce their behavior, create equivalents from import.meta.url:

import { fileURLToPath } from 'url';
import path from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

These small details make your scripts reliable and consistent whether you are reading data, generating reports, or serving files from a web server.

Process, environment, and args

Every Node.js program runs inside a single process. The process object gives you access to information about that process, including environment variables, command line arguments, and standard input and output streams.

This global object is always available without importing it, and provides a way to configure or inspect your program’s runtime environment.

Inspecting the current process

You can print details about the process directly from any script:

console.log(process.pid);       // Process ID
console.log(process.platform);  // OS platform
console.log(process.cwd());     // Current working directory

The process.cwd() function returns the directory from which Node.js was launched, not necessarily the directory containing the script file. This is useful when resolving relative paths or logging context information.

💡 Use process.exit(code) to end a program with a specific exit code. A zero value means success, while non-zero values indicate errors or abnormal termination.

Accessing environment variables

Environment variables are stored in process.env. They are key-value pairs set by the operating system or shell and are commonly used for configuration, such as database credentials, API keys, or log settings.

console.log(process.env.PATH);
console.log(process.env.NODE_ENV);

You can set environment variables when running a command:

NODE_ENV=production node app.js

On Windows, use the set command first:

set NODE_ENV=production && node app.js
⚠️ Never hard-code secrets such as API keys in source files. Store them in environment variables or configuration files that are excluded from version control.

Environment variables are always strings. Convert them manually to numbers or booleans when needed:

const port = Number(process.env.PORT) || 3000;
const debug = process.env.DEBUG === 'true';

Reading command line arguments

Arguments passed to your script appear in the array process.argv. The first two entries are the Node.js executable and the script filename. The rest are the actual user arguments.

// Run: node app.js hello world
console.log(process.argv);
// → ['/usr/bin/node', '/path/app.js', 'hello', 'world']

To simplify argument handling, slice off the first two entries:

const args = process.argv.slice(2);
console.log(args); // ['hello', 'world']

For more complex command line tools, use a library such as yargs or commander to define options, flags, and help text.

Standard input and output

Every Node.js process has access to three standard streams: stdin, stdout, and stderr. You can use them for text input and output directly:

process.stdout.write('Enter your name: ');
process.stdin.on('data', data => {
  console.log('Hello', data.toString().trim());
  process.exit(0);
});

These streams provide fine-grained control for CLI tools, where user interaction, logging, and piping data between processes are common.

By combining process methods, environment variables, and command line arguments, you can make your scripts flexible, configurable, and ready for real-world automation tasks.

HTTP client and server (overview)

Node.js was originally built for networked applications. Its event-driven design and non-blocking I/O make it ideal for serving many concurrent HTTP connections efficiently. The built-in http module allows you to create both simple servers and basic clients without external dependencies.

Creating a simple HTTP server

You can create a working web server in just a few lines. The http.createServer() function takes a callback that runs each time a client connects:

import http from 'http';

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello from Node.js\n');
});

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

This server listens on port 3000 and responds with plain text to any request. The req object provides request details, such as the URL and headers, while res is used to send back a response.

💡 Use res.setHeader() to define custom headers, and always call res.end() to finish the response. Without it, the client will wait indefinitely.

By adding simple conditionals, you can serve different content based on the request URL:

const server = http.createServer((req, res) => {
  if (req.url === '/about') {
    res.end('About page');
  } else {
    res.end('Home page');
  }
});

Making HTTP requests as a client

Node.js can also make HTTP requests. You can use the built-in http.request() or https.request() functions, though most developers now prefer the modern fetch() API, which is available globally in recent Node.js versions.

const response = await fetch('https://example.com');
const text = await response.text();
console.log(text);

This works exactly as it does in browsers, supporting JSON responses, headers, and streaming data. You can also use await response.json() to decode JSON directly.

⚠️ In older versions of Node.js, fetch may not be defined. To use it in those environments, install a lightweight polyfill such as node-fetch.

Serving files and JSON

To serve more useful content, combine the fs module with http to read and send files or structured data:

import http from 'http';
import fs from 'fs/promises';

http.createServer(async (req, res) => {
  if (req.url === '/data') {
    const json = { message: 'Hello, JSON!' };
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(json));
  } else {
    const html = await fs.readFile('index.html');
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(html);
  }
}).listen(3000);

This demonstrates how dynamic and static responses can coexist in a minimal Node.js server.

Modern frameworks and utilities

Although the core http module is powerful, many developers use frameworks such as express or fastify for higher-level routing, middleware, and better error handling. These frameworks build upon the same foundations while improving readability and maintainability.

For client-side requests beyond simple fetch(), libraries like axios offer convenient wrappers that support automatic JSON parsing, interceptors, and timeout control.

Together, these tools make Node.js an effective environment for building APIs, backend services, or automation scripts that communicate over the web.

Packages with npm and scripts

Node.js uses the Node Package Manager (npm) to install, share, and manage external modules. The npm registry hosts millions of open-source packages, covering everything from web servers to testing tools.

Each Node.js project is defined by a package.json file that records metadata, dependencies, and script commands. You can create this file automatically using:

npm init -y

This generates a minimal package.json that looks like this:

{
  "name": "myapp",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  }
}

The type field determines whether the project defaults to ECMAScript modules or CommonJS. The scripts section defines custom commands that can be run with npm run <name>.

Installing and using packages

To add a dependency, use npm install followed by the package name. For example, to install Express:

npm install express

This creates a node_modules directory and updates your package.json and package-lock.json files. The module can then be imported into your script:

import express from 'express';

const app = express();
app.get('/', (req, res) => res.send('Hello, world!'));
app.listen(3000);
💡 Use --save-dev to install development-only dependencies, such as testing frameworks or build tools. These will not be included in production installations.

You can also install specific versions or version ranges:

npm install lodash@4.17.21

Use npm outdated to check for newer versions and npm update to upgrade packages safely.

Running scripts

Scripts are predefined command shortcuts stored in package.json. They are run with npm run, and can be used for tasks such as testing, building, or linting.

{
  "scripts": {
    "start": "node index.js",
    "dev": "node server.js",
    "test": "node test.js"
  }
}
npm run start
npm run test

The start script is special: it can be run as npm start without the run keyword.

⚠️ Avoid naming scripts after existing npm commands, such as install or update. Doing so overrides the default behavior and may cause confusion.

Global vs local packages

Packages installed locally go into your project’s node_modules directory. To install tools for global use (such as command line utilities), use the -g flag:

npm install -g nodemon

Global packages add executables to your system’s PATH, allowing you to run them directly from the terminal.

Lock files and reproducibility

npm automatically maintains a package-lock.json file, which records the exact versions of all installed dependencies. This ensures that everyone working on the same project installs identical versions, improving consistency across environments.

When sharing your project, commit package-lock.json to version control so that collaborators and CI systems reproduce your dependency tree exactly.

Together, npm, package.json, and the scripts system form the backbone of Node.js project management. They make it easy to install tools, automate workflows, and share your work with others in a reliable and standardised way.