Getting Started with Node.js: Install, Setup, and Hello World
Key takeaways
Node.js tutorial for beginners: install Node and npm on Windows, macOS, and Linux, run Hello World, use npm scripts, and understand modules, fs, and HTTP—aligned with how production apps are built.
Introduction
What is Node.js?
Node.js is a JavaScript runtime built on Chrome’s V8 engine. It lets you run JavaScript outside the browser.
Key characteristics:
- Non-blocking I/O: High throughput for I/O-heavy workloads
- Single-threaded model: Event loop–driven concurrency
- Cross-platform: Windows, macOS, and Linux
- npm: The world’s largest package ecosystem
- One language: Share JavaScript between frontend and backend
“Single-threaded” needs a precise reading, because it is the source of both Node’s strengths and its most common production problems. Your JavaScript runs on one thread, driven by an event loop. I/O — reading files, talking to databases, handling sockets — is handed off to the operating system or to libuv’s small thread pool, and your callback runs when the result is ready. While one request waits for the database, the same thread is free to handle hundreds of others, which is why a single Node process can serve many concurrent connections with little memory. The flip side: anything that keeps the JavaScript thread busy — a big JSON.parse, a synchronous file read, a tight loop — pauses every request in that process until it finishes.
Good fits for Node.js:
- REST and JSON APIs
- Real-time apps (chat, games, collaboration)
- Microservices and BFF layers
- CLI tools and build scripts
- Streaming and upload pipelines
Poor fits:
- CPU-heavy work (image/video transcoding, large numerical jobs)
- Work that needs true parallel CPU crunching (use workers, native addons, or another runtime)
“Poor fit” does not mean “impossible”. The worker_threads module runs JavaScript on additional threads, and many CPU-heavy libraries (image processing with sharp, hashing with bcrypt) are native code that runs off the main thread. What Node is bad at is CPU-heavy work written in plain JavaScript on the request path.
Node.js vs browser JavaScript
| Aspect | Browser JavaScript | Node.js |
|---|---|---|
| Environment | Browser (Chrome, Firefox, …) | Server, local machine |
| Global | window | global (both support globalThis) |
| DOM | Yes | No |
| File system | No | Yes (fs) |
| HTTP server | No (without bundling/workarounds) | Yes (http, https) |
| Modules | ES Modules (modern) | CommonJS + ES Modules |
| Package manager | None built-in | npm, yarn, pnpm |
Shared JavaScript APIs
console.log("Hello");
setTimeout(() => console.log("After 1s"), 1000);
setInterval(() => console.log("Tick"), 1000);
const fetchData = async () => {
const result = await Promise.resolve("data");
return result;
};
const obj = JSON.parse('{"name": "Alice"}');
const str = JSON.stringify({ name: "Alice" });
The overlap is larger than it used to be. Since Node 18, fetch, Request, Response, URL, AbortController, TextEncoder, structuredClone, and Web Crypto are available globally, so a lot of code can run unchanged in both environments. The timer functions look identical but are not quite: in Node, setTimeout returns a Timeout object (with .unref() to stop it from keeping the process alive), not a number as in browsers — which occasionally trips up TypeScript code shared between the two.
Node-only APIs
const fs = require('fs');
const content = fs.readFileSync('file.txt', 'utf8');
const http = require('http');
const server = http.createServer((req, res) => res.end('Hello'));
const path = require('path');
const filePath = path.join(__dirname, 'file.txt');
console.log(process.version, process.platform, process.cwd(), process.pid);
Prefer async fs methods on servers; sync calls block the event loop.
Sync calls are fine — often better — in short scripts and at startup (reading a config file once before the server listens), because nothing else is waiting. The rule is about code that runs per request. Note also that readFileSync('file.txt') resolves against process.cwd(), the directory you ran node from, while path.join(__dirname, 'file.txt') resolves against the script’s own folder; mixing the two up produces ENOENT: no such file or directory errors that appear only when the script is started from a different directory.
Installing Node.js
Windows
- nodejs.org → download LTS
- Run the installer
- Verify:
node --version,npm --version
macOS
Official installer or brew install node.
Linux (Ubuntu/Debian example)
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
nvm install --lts
nvm use --lts
Choose an LTS (long-term support) release: even-numbered major versions enter LTS in October of their release year and receive fixes for about 30 months, while odd-numbered versions are short-lived. Check the release schedule on nodejs.org before pinning a version, because a version that was current when a tutorial was written may already be end-of-life. The distribution’s own apt install nodejs package is often several majors behind, which is why the NodeSource repository or a version manager is the usual route on Linux.
For development machines, a version manager (nvm on macOS/Linux, nvm-windows or fnm on Windows) is worth the extra step: different projects need different Node versions, and a .nvmrc file in the repository lets everyone run nvm use to get the right one. A common beginner trap is installing global packages with sudo npm install -g on a system Node, which leads to EACCES: permission denied errors later; with nvm, global installs go into your home directory and need no sudo.
First programs
Hello World
console.log("Hello, Node.js!");
console.log(`Node ${process.version}, platform ${process.platform}`);
Save it as hello.js and run node hello.js. There is no compile step and no main function: Node executes the file top to bottom, and the process exits by itself once there is nothing left to do — no pending timers, sockets, or callbacks. That automatic exit is why a server keeps running (its listening socket keeps the event loop alive) while a script ends.
CLI arguments
console.log(process.argv);
const args = process.argv.slice(2);
if (args.length === 0) {
console.log("Usage: node args.js <name>");
process.exit(1);
}
console.log(`Hello, ${args[0]}!`);
process.argv[0] is the path to the node executable and argv[1] the script path, which is why real arguments start at index 2. For anything beyond one or two positional arguments, the built-in util.parseArgs (Node 18.3+) parses --flag value options without a dependency. Exit codes matter for scripts: process.exit(1) signals failure to shells and CI systems, which only check the exit code, not the output.
Environment variables
const port = process.env.PORT || 3000;
const nodeEnv = process.env.NODE_ENV || 'development';
console.log(port, nodeEnv);
Environment variables are always strings (or undefined), so process.env.PORT is "8080", not 8080, and process.env.DEBUG === true is never true. Convert explicitly (Number(process.env.PORT) || 3000). Truthiness checks are misleading here: || treats an empty PORT= as missing, but "0" and "false" are non-empty strings and therefore truthy — another reason to parse values rather than rely on truthiness. NODE_ENV=production is a convention many libraries (Express among them) read to switch off development behavior, so set it in production deployments.
First HTTP server
const http = require('http');
const server = http.createServer((req, res) => {
console.log(`${req.method} ${req.url}`);
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Hello from Node.js!');
});
const PORT = 3000;
server.listen(PORT, () => console.log(`http://localhost:${PORT}`));
Add routing by branching on req.url and req.method; return JSON with JSON.stringify and Content-Type: application/json.
The callback runs once per request, with req (a readable stream of the incoming request) and res (a writable stream for the response). Two mistakes are almost universal on first contact. Forgetting res.end() in some branch leaves the browser spinning until it times out, because the response never finishes. Calling res.end() or res.writeHead() twice throws Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client. When you open the page in a browser you will also see two requests logged — the second is the browser asking for /favicon.ico. Reading a POST body requires collecting the stream’s data events and parsing them yourself, which is one of the first things frameworks like Express do for you.
npm
npm init -y
npm install express
npm install --save-dev nodemon
{
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
}
}
npm install express does three things: downloads the package and its dependencies into node_modules, records it in package.json under dependencies, and writes exact resolved versions to package-lock.json. Commit package.json and the lockfile, never node_modules; on another machine or in CI, npm ci recreates the exact same tree from the lockfile. --save-dev marks tools that are only needed during development, so production installs (npm ci --omit=dev) skip them. Scripts run with node_modules/.bin on the PATH, which is why "dev": "nodemon index.js" works without a global install; run them with npm run dev (start and test also work without run).
Modules
CommonJS: module.exports / require. ES modules: "type": "module" in package.json, then import / export. Same patterns as the modules article.
The practical consequence for a beginner is that copy-pasted examples fail in confusing ways when they use the other system: import in a CommonJS file throws SyntaxError: Cannot use import statement outside a module, and require in an ES module throws ReferenceError: require is not defined in ES module scope. Decide per project — new projects usually choose ES modules — and set "type" in package.json accordingly.
File system (fs)
Sync (readFileSync), callback (readFile), and Promise (require('fs').promises) styles exist—use Promises + async/await for server code.
The three styles are the same operations with different ways of delivering the result. The callback style follows Node’s “error-first” convention ((err, data) => {}), which you will still see in older code and documentation; the promise style (require('fs/promises') or fs.promises) reads naturally with async/await. Two details worth knowing early: passing an encoding like 'utf8' returns a string, while omitting it returns a Buffer of raw bytes; and readFile loads the entire file into memory, so for large files (logs, uploads, exports) use streams (fs.createReadStream) instead. The filesystem guide covers both.
Examples
Minimal multi-route server, static file server, and CLI directory counter—see the filesystem guide for full listings.
If you build the static file server yourself as an exercise, guard against path traversal: joining req.url directly onto a folder lets a request for /../../etc/passwd escape it. Resolve the final path and check that it still starts with your public directory before reading, or use a well-tested module such as serve-static in production.
nodemon
npm install --save-dev nodemon
{ "scripts": { "dev": "nodemon server.js" } }
nodemon restarts the process whenever a watched file changes, so you don’t have to stop and restart the server after each edit. Recent Node versions include the same basic feature: node --watch server.js (stable since Node 22) restarts on changes to the script and the files it imports, which is enough for many projects without an extra dependency. Either way, a restart loses in-memory state such as sessions stored in a variable.
Debugging
VS Code launch.json with "type": "node", or node inspect script.js.
The most useful option for beginners is node --inspect server.js (or --inspect-brk to pause on the first line): it opens a debugging port that Chrome DevTools (chrome://inspect) or VS Code can attach to, giving you breakpoints, variable inspection, and CPU and memory profiling for server code. VS Code’s “JavaScript Debug Terminal” does the same automatically for any node or npm run command started in it. console.log debugging works too, but console.dir(obj, { depth: null }) is worth remembering, because plain console.log collapses nested objects beyond two levels into [Object].
dotenv
npm install dotenv
Load secrets from .env; never commit .env (list in .gitignore).
Call require('dotenv').config() (or import 'dotenv/config') as the very first line of your entry file — modules that read process.env at import time otherwise see undefined. Since Node 20.6, node --env-file=.env server.js loads the file natively, which covers the common case without a dependency. Commit a .env.example with the variable names and dummy values so new developers know what to set; in production, set real environment variables through your hosting platform rather than shipping a .env file.
Common issues
- EADDRINUSE — port busy; pick another port or kill the process
- Cannot find module — run
npm install - Wrong path — use
path.join(__dirname, ...) - Async mistakes — use
await/.catch()consistently
A few more specifics for each. Error: listen EADDRINUSE: address already in use :::3000 usually means a previous run of your own server is still alive — often a nodemon process in another terminal; find it with lsof -i :3000 on macOS/Linux or netstat -ano | findstr :3000 on Windows. Error: Cannot find module 'express' means the package is not installed in this project (or you ran node from a different folder), while Cannot find module './routes' is a path or file-name problem — remember that Linux file systems are case-sensitive even when your laptop’s is not. Async mistakes most often show up as UnhandledPromiseRejection crashes: since Node 15, an unhandled rejected promise terminates the process by default, so every async call path needs a try/catch or a .catch() somewhere.
Crash handlers, shutdown, and using more than one core
Before a server goes anywhere near production, it needs handlers for uncaughtException and unhandledRejection, and a SIGTERM handler that closes the server (server.close()) so in-flight requests finish before the process exits. Container platforms send SIGTERM first and kill the process a few seconds later, so without that handler every deploy cuts off active requests.
process.on('uncaughtException') is for logging and exiting, not for continuing. After an uncaught exception the process may be in an inconsistent state, so the recommended pattern is to log the error, stop accepting new work, and exit with a non-zero code, letting a process manager (systemd, PM2, Docker’s restart policy, Kubernetes) start a fresh instance. The cluster module (or simply running several instances behind a load balancer) spreads requests across CPU cores; it does not make a single CPU-heavy request faster — that is what worker_threads is for.
Next in the series
The idea to carry forward from this first step is the event loop: Node is fast when the main thread is free to move between waiting requests, and slow when something occupies it. The next parts build on that directly:
Related Articles
- Node.js series
- JavaScript introduction
- Modules: CommonJS and ES modules
- Working with the file system
Frequently Asked Questions (FAQ)
Q. Should I use require or import in a new Node.js project?
A. Node.js supports both: CommonJS (require) is the default for .js files, and ES modules (import) are enabled with "type": "module" in package.json or the .mjs extension. ES modules are the standard going forward, but some CommonJS habits change: __dirname and __filename do not exist there, so use import.meta.dirname on recent Node versions or fileURLToPath(import.meta.url). Pick one style per project so you do not end up debugging interop errors between the two.