Modules, the file system, HTTP servers, and async I/O — JavaScript on the server.
Node.js is a runtime that runs JavaScript outside a browser — on a server, in a script, or as a CLI tool. It's the same JavaScript language you already know; what's different is the environment: no window, no document, no DOM, but real access to the file system, network, and operating system that a browser deliberately hides from web pages for security.
Node runs on Chrome's V8 engine (the same one powering the Chrome browser) plus a set of built-in modules (fs, http, path, and more) that expose that server-level access. A .js file run with node app.js executes top to bottom, same as any JavaScript file, just with a different set of APIs available.
Node is fundamentally single-threaded but handles many operations concurrently through an event loop — long-running work like reading a file or a network request doesn't block other code from running meanwhile, as long as it's written using Node's async APIs rather than their blocking/Sync counterparts.
console.log("Running in Node, not a browser!");
console.log(process.version);Every Node file is its own module — private by default, nothing inside it is visible elsewhere unless explicitly exported. module.exports is a special object every file gets automatically; whatever you assign to it is exactly what another file receives when it require()s this one.
require("./utils") (a path starting with ./ or ../) loads your own file; require("path") or require("express") (a bare name) loads either a Node built-in or an installed npm package — Node checks built-ins first, then node_modules.
Exporting more than one thing is done by assigning an object: module.exports = { add, subtract } — the file requiring it then destructures whichever named exports it actually needs: const { add } = require("./math").
function add(a, b) {
return a + b;
}
module.exports = { add };const { add } = require("./math");
console.log(add(2, 3)); // 5package.json describes a Node project: its name, version, the scripts it defines (npm run dev, npm test), and its dependencies — other packages the project needs installed to run. npm init creates a starter one; npm install <package> adds a new dependency and records it there automatically.
Installed packages live in a node_modules folder, which is regenerated from package.json (npm install with no arguments reads the file and installs everything listed) rather than being committed to version control — it's routinely excluded via .gitignore.
dependencies are needed at runtime (the app won't work without them); devDependencies (npm install --save-dev) are only needed during development — a test runner or a linter, for instance — and aren't required just to run the finished app.
{
"name": "my-app",
"version": "1.0.0",
"scripts": { "start": "node index.js" },
"dependencies": { "express": "^4.18.0" }
}The built-in fs module reads and writes files. fs.readFileSync(path, "utf8") reads a whole file synchronously and returns it as a string — without the encoding argument, it returns a raw Buffer of bytes instead of readable text.
"Sync" in a function name is a real warning: fs.readFileSync blocks Node's single thread entirely until the read finishes. That's fine for a one-off script or reading startup config, but a real server handling multiple requests should use the async version instead — fs.readFile with a callback, or fs.promises.readFile with await.
fs.readFileSync throws if the file doesn't exist rather than returning null — wrapping a read in try/catch is the standard way to handle a missing file without crashing the whole process.
const fs = require("fs");
const text = fs.readFileSync("notes.txt", "utf8");try {
fs.readFileSync("missing.txt", "utf8");
} catch (err) {
console.log("File not found");
}fs/promises (require("fs/promises")) is a Promise-based version of the same fs API — await pauses an async function until the Promise resolves, without blocking the rest of Node the way the Sync functions do. Other requests or timers can still run during that wait.
await can only be used inside a function declared async — using it at the top level of a regular function is a syntax error. Modern Node (recent versions) does also support top-level await in ES modules specifically, an exception to that rule.
The older, callback-based fs API (fs.readFile(path, (err, data) => {...})) still exists and works fine, but doesn't return a Promise — await won't work on it directly without wrapping it (Node's util.promisify can convert an old callback-style function into a Promise-returning one).
const fs = require("fs/promises");
async function main() {
const data = await fs.readFile("config.json", "utf8");
console.log(JSON.parse(data));
}const [a, b] = await Promise.all([
fs.readFile("a.txt", "utf8"),
fs.readFile("b.txt", "utf8"),
]);http.createServer((req, res) => {...}) builds a raw HTTP server — the callback runs once per incoming request. req describes what came in (method, URL, headers); res is how a response gets built and sent back. Nothing reaches the client until something is called on res.
server.listen(port) is what actually starts accepting connections — createServer alone only constructs the server object in memory, bound to nothing yet. The optional callback to listen() fires once the port is genuinely ready.
res.end() sends the response and marks it complete — calling it a second time for the same request throws, since an already-finished response can't be sent again. This raw http module is what frameworks like Express (next section) are themselves built on top of.
const http = require("http");
http.createServer((req, res) => {
res.end("Hello from Node!");
}).listen(3000, () => {
console.log("Listening on port 3000");
});Express is the near-universal framework for building Node servers — it replaces one big createServer callback with a method per HTTP verb, per path: app.get("/users", handler), app.post("/users", handler), and so on. Express dispatches based on the method and path together, not just the path alone.
res.json(data) is Express's convenience method — it sets the Content-Type: application/json header and serializes the value for you, something the plain http module has no equivalent for (you'd set the header and call JSON.stringify manually).
Route paths can include named parameters: app.get("/users/:id", (req, res) => { const id = req.params.id; ... }) — a common next step once a static path like /users isn't specific enough.
app.get("/users", (req, res) => {
res.json(["Ada", "Bo"]);
});app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});process.env is an object exposing environment variables — settings passed in from outside the code itself, like PORT, DATABASE_URL, or an API key. process.env.PORT reads whatever value was set in the environment the process was started in.
Environment variables are how real apps keep secrets (API keys, database credentials) out of the source code — a value in process.env is set on the hosting platform (or a local .env file, loaded by a package like dotenv) rather than hardcoded and committed to version control.
Every value read from process.env is a string, even if it looks numeric — process.env.PORT is "3000", not the number 3000, so comparing or using it arithmetically without converting first (Number(process.env.PORT)) can produce a subtle bug.
const port = process.env.PORT || 3000;
app.listen(port);const port = Number(process.env.PORT) || 3000;The "scripts" field in package.json defines named shortcuts run with npm run <name> — npm run dev, npm test (a special case that doesn't need "run"), npm start. They're just shell commands with a memorable name, nothing more magical than that.
"start" and "test" are the two script names npm treats specially — npm start (no "run" needed) and npm test both work without the "run" keyword, purely by convention; every other script name needs the full npm run <name>.
Scripts can call other locally-installed command-line tools directly by name (npm run lint might just run eslint .) without needing a global install or a full path — npm automatically adds node_modules/.bin to the path while a script runs.
{
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "vitest"
}
}try/catch around an await catches a rejected Promise the same way it catches a thrown error in synchronous code — a failed fs.readFile or a failed network request inside an async function is caught exactly like any other exception, as long as it's awaited inside the try block.
An uncaught error inside an Express route handler, if not passed to Express's error-handling mechanism, can crash the entire server process for every user, not just the one request that failed — wrapping async route handler bodies in try/catch (or a small wrapper utility that does it automatically) is standard practice, not optional polish.
process.on("uncaughtException", ...) exists as a last-resort safety net for truly unexpected errors, but relying on it as the primary error-handling strategy is considered bad practice — by the time it fires, the process may already be in an inconsistent state; real error handling belongs at the specific try/catch or .catch() closest to where the error can actually occur.
app.get("/users", async (req, res) => {
try {
const users = await db.getUsers();
res.json(users);
} catch (err) {
res.status(500).json({ error: "Something went wrong" });
}
});