Skip to content
Chapter 5

Node.js: JavaScript on the Server

Taking JavaScript beyond the browser

For 14 years, JavaScript lived in a cage. The browser. It could make buttons glow and forms validate, but the moment you needed real power -- reading files, talking to databases, running a server -- you hit a wall. You had to switch to a completely different language. Then in 2009, Ryan Dahl opened the cage door. He took Chrome's V8 JavaScript engine, gave it access to the file system, the network, and the operating system, and called it Node.js. Suddenly, the same language you learned in Chapter 2 -- the one that powers every browser on Earth -- could power your entire backend too. And it turns out this freed fish can cook. One chef, one thread, ten thousand meals a day. By the end of this chapter, you'll understand how.

What Node.js Actually Is (And What It Isn't)

Node.js is not a programming language. This confuses almost everyone at first, so let's clear it up right now.

JavaScript is the language. Node.js is a place where JavaScript can run. Think of it this way: a fish is a fish whether it's in a lake or an aquarium. JavaScript is JavaScript whether it's running in Chrome or in Node.js. The difference is the environment around it -- what it can touch, what it can do.

In the browser, JavaScript can manipulate the DOM, respond to clicks, and animate things on screen. But it can't read files on your hard drive or listen on a network port -- that would be a massive security risk. Node.js flips this around. There's no DOM, no window, no document. Instead, you get access to the file system, the network, the operating system itself. Same fish, completely different water.

Here's a question before you read on: in Chapter 4, you learned how the frontend sends API requests to a server. What language do you think that server could be written in?

The answer, thanks to Node.js, is JavaScript. The same language on both sides of the conversation. Under the hood, Node.js uses Google's V8 engine -- the exact same engine that makes Chrome fast -- to execute your JavaScript at near-native speeds. But it wraps V8 with system-level capabilities the browser could never allow. You can read and write files. You can create HTTP servers. You can connect to databases, spawn processes, and stream data. The language you already know just got a whole lot more powerful.

npm: Two Million Tools, One Command

What if there was a store with over two million free tools, and you could grab any of them with a single command? That's npm.

npm stands for Node Package Manager, and it comes bundled with Node.js -- install one, you get the other. But npm is really two things at once. First, it's a command-line tool you use to install packages. Second, it's a massive online registry where developers around the world publish open-source code for anyone to use.

Need to build a web server? One command: `npm install express`. Need to hash passwords? `npm install bcrypt`. Send emails, parse CSV files, generate PDFs, connect to any database -- there's a package for all of it. Instead of writing everything from scratch, you snap together well-tested building blocks that thousands of other developers rely on every day.

When you run `npm install express`, npm downloads Express and all of its dependencies into a `node_modules` folder in your project. Your `package.json` file is the manifest -- it records which packages your project needs. Think of it as the ingredient list for your application. And `package-lock.json` locks down the exact versions installed, so every developer on your team gets the identical setup. No "works on my machine" surprises.

Before reading on, think about this: why is it useful that `package.json` exists as a separate file from the actual packages in `node_modules`?

Because `node_modules` can be enormous -- hundreds of megabytes. You never commit it to version control. Instead, a new developer clones your project, runs `npm install`, and npm reads `package.json` to reconstruct the entire `node_modules` folder from scratch. One file, thousands of packages, perfectly reproducible.

You might also encounter alternatives to npm. Yarn (by Facebook) and pnpm (which uses a cleverer storage strategy) are popular choices that work with the same registry. And `npx` lets you run a package without permanently installing it -- handy for one-off commands like `npx create-next-app`, which you'll see in the very next chapter.

package.jsonjson
{
  "name": "my-api-server",
  "version": "1.0.0",
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "build": "tsc",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.18.2",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "bcrypt": "^5.1.1",
    "jsonwebtoken": "^9.0.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "typescript": "^5.3.3",
    "jest": "^29.7.0"
  }
}

Building a Server with Express: 20 Lines to Liftoff

Twenty lines of code. A working server. Let's build one.

In Chapter 4, you stood on the frontend side of the API conversation -- sending requests and waiting for responses. Now you're crossing to the other side. You're building the thing that receives those requests and decides what to send back. That shift is what makes you a full-stack developer, and Express is the tool that makes it shockingly simple.

Express is the most popular web framework for Node.js. It takes Node's raw HTTP capabilities and wraps them in an API so clean it almost feels like cheating. Instead of manually parsing URLs and crafting raw HTTP responses, you describe your server in plain terms: "When someone sends a GET request to /api/todos, run this function and send back this data."

At its core, Express works with three ideas. Routes map a combination of HTTP method + URL path to a handler function. Middleware processes requests through a pipeline before they reach your route (parsing JSON bodies, checking authentication, logging). And the request/response pair -- `req` gives you everything the client sent, `res` lets you send something back.

Look at the code example below. That's a complete CRUD API for a todo list. GET, POST, PATCH, DELETE -- all the HTTP methods you learned about in Chapter 4, now handled by code you wrote yourself. Notice how each route reads almost like English: "app.get /api/todos, then respond with the todos list." That readability is why Express has remained the go-to choice for over a decade.

Here's what should excite you: you already understand both sides of the conversation now. The frontend sends `fetch("/api/todos")`, and now you can see exactly what happens when that request arrives. The wall between frontend and backend just came down.

server.tstypescript
import express from "express"
import cors from "cors"

const app = express()

// Middleware: parse JSON bodies and enable CORS
app.use(express.json())
app.use(cors())

// In-memory data store (in reality, you'd use a database)
let todos = [
  { id: 1, text: "Learn Node.js", completed: false },
  { id: 2, text: "Build an API", completed: false },
]
let nextId = 3

// GET /api/todos - List all todos
app.get("/api/todos", (req, res) => {
  res.json(todos)
})

// GET /api/todos/:id - Get a specific todo
app.get("/api/todos/:id", (req, res) => {
  const todo = todos.find((t) => t.id === Number(req.params.id))
  if (!todo) {
    return res.status(404).json({ error: "Todo not found" })
  }
  res.json(todo)
})

// POST /api/todos - Create a new todo
app.post("/api/todos", (req, res) => {
  const { text } = req.body
  if (!text) {
    return res.status(400).json({ error: "Text is required" })
  }

  const newTodo = { id: nextId++, text, completed: false }
  todos.push(newTodo)
  res.status(201).json(newTodo)
})

// PATCH /api/todos/:id - Update a todo
app.patch("/api/todos/:id", (req, res) => {
  const todo = todos.find((t) => t.id === Number(req.params.id))
  if (!todo) {
    return res.status(404).json({ error: "Todo not found" })
  }

  Object.assign(todo, req.body)
  res.json(todo)
})

// DELETE /api/todos/:id - Delete a todo
app.delete("/api/todos/:id", (req, res) => {
  todos = todos.filter((t) => t.id !== Number(req.params.id))
  res.status(204).send()
})

// Start the server
app.listen(3001, () => {
  console.log("API server running at http://localhost:3001")
})

The Event Loop: One Thread, Ten Thousand Meals

There's one chef in this kitchen. Just one. And somehow, they serve 10,000 meals a day. Every restaurant critic says it's impossible. But the chef has a secret.

Here's the tension: Node.js runs on a single thread. One. In a world where traditional servers like Java or .NET spin up a new thread for every incoming request -- sometimes thousands of threads running simultaneously -- Node uses just one. That sounds like it should be disastrously slow. So why is Node.js one of the fastest server platforms in existence?

Because the chef never stands still.

Picture our chef. Order comes in: steak, medium-rare. The chef seasons it, throws it on the grill, and -- here's the key -- does NOT stand there watching it cook. Instead, they immediately turn to the next order. Pasta? Start boiling water. While water heats, chop vegetables for order three. The grill timer dings? Pull the steak, plate it, send it out. Back to the pasta. The chef is one person, but they're constantly cycling between tasks at the natural waiting points.

That's the event loop. When Node.js hits an operation that takes time -- reading a file, querying a database, making a network request -- it doesn't sit there waiting. It hands that operation off to the operating system, says "let me know when you're done," and immediately moves on to handle the next request. When the result comes back, a callback gets placed in a queue, and the event loop picks it up when the main thread is free.

This is why Node.js dominates I/O-heavy workloads like web servers, APIs, and real-time apps. Most of the "work" in a web server is actually waiting -- waiting for database responses, waiting for files to load, waiting for external APIs to reply. Node turns that waiting time into productive time.

But here's the trap. What happens if you give the chef a task that requires their undivided attention -- say, hand-grinding a hundred pounds of sausage? Every other order stops. Nobody gets served until the grinding is done.

That's what happens when you block the event loop with CPU-intensive work. A heavy computation, a synchronous file read, a massive loop -- any of these will freeze your entire server. All 10,000 pending meals go cold. Look at the code example below: the `badFibonacci` function blocks for seconds, and during that time, zero other requests can be handled. The async database query, on the other hand, lets Node keep serving while it waits for results.

The rule is simple: never make the chef stand still. Keep I/O async, keep computation light, and the single thread becomes your greatest strength instead of your weakness.

event-loop.jsjavascript
// This BLOCKS the event loop -- don't do this!
function badFibonacci(n) {
  if (n <= 1) return n
  return badFibonacci(n - 1) + badFibonacci(n - 2)
}

// While this runs, NO other requests can be handled
app.get("/api/fib-bad", (req, res) => {
  const result = badFibonacci(45) // Blocks for seconds!
  res.json({ result })
})

// This is non-blocking -- the correct approach
app.get("/api/users", async (req, res) => {
  // These are async operations that don't block
  const users = await db.query("SELECT * FROM users")
  // Node handles other requests while waiting for the DB
  res.json(users)
})

// Understanding execution order with the event loop
console.log("1. Synchronous - runs first")

setTimeout(() => {
  console.log("4. Timeout callback - runs last")
}, 0)

Promise.resolve().then(() => {
  console.log("3. Promise - runs after sync, before timeout")
})

console.log("2. Synchronous - runs second")

// Output:
// 1. Synchronous - runs first
// 2. Synchronous - runs second
// 3. Promise - runs after sync, before timeout
// 4. Timeout callback - runs last
Key Takeaways
  • Node.js is NOT a language -- it's a runtime that lets JavaScript escape the browser and access files, networks, and operating system APIs. Same language you learned in Chapter 2, radically new capabilities.
  • npm gives you access to over 2 million free packages with a single command. Your package.json is the recipe; node_modules is the pantry. Never build from scratch what the community has already solved.
  • Express turns building a web server into a 20-line exercise. Routes, middleware, request/response -- you're now on the other side of those API calls from Chapter 4.
  • The event loop is Node's superpower: one thread that never blocks, handling thousands of concurrent connections by cycling between tasks at natural waiting points -- like a chef who never stands still.
  • The one rule of Node.js: never block the event loop. Keep I/O asynchronous, keep computation light, and your single thread will outperform servers with hundreds of threads.
Quiz
Question 1 of 3

What is Node.js?