1. The Event Loop & Non-Blocking I/O
Node.js runs your JavaScript on a single thread, which sounds like a
limitation until you understand what it doesn't block on. When your code calls
something I/O-bound — reading a file, querying a database, making an HTTP request —
Node hands that work off to the underlying system (via libuv's thread
pool or the OS's async I/O) and keeps executing other code. When the I/O finishes, its
callback is queued to run on the main thread. That queue-and-resume mechanism is the
event loop.
console.log("1: start");
setTimeout(() => console.log("2: timeout"), 0);
fs.readFile(__filename, () => console.log("3: file read done"));
console.log("4: end");
// Output order: 1, 4, 3 (or 2), 2 (or 3)
// "1" and "4" run synchronously, back to back -- the event loop
// never gets a chance to run anything else until the synchronous
// code finishes. "2" and "3" are both async callbacks whose exact
// relative order depends on I/O timing, but BOTH always run after
// every synchronous line above them, never interleaved with it.
The practical consequence: any CPU-bound work you do synchronously — a huge JSON parse, a tight loop, a heavy computation — blocks that single thread completely, and every other request your server is handling stalls until it finishes. This is the single most important mental model in this course: Node's concurrency model is excellent for I/O-bound work (which is most of what a typical REST API does — waiting on a database, waiting on another service) and actively bad for CPU-bound work done on the main thread.
Every route handler you write in Weeks 2 onward runs on this same single thread. A route that does JSON.parse() on a huge payload synchronously, or runs a heavy synchronous loop, blocks every other concurrent request your server is handling — not just the one that triggered it. Keeping route handlers non-blocking is a running theme for the rest of this course, not just a Week 1 detail.
2. Modules, npm/pnpm & Project Structure
Modern Node projects use ES modules (import/
export) rather than the older CommonJS require/
module.exports — this course uses ES modules throughout, since that's
also what TypeScript and modern tooling default to:
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
import { add, PI } from "./math.js"; // note the .js extension, even in a .ts file
console.log(add(2, 3), PI);
package.json is every Node project's manifest — dependencies, scripts,
and metadata npm uses to install and run your project:
{
"name": "week-01-fundamentals",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/main.js",
"dev": "tsx watch src/main.ts"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.5.0",
"tsx": "^4.16.0",
"@types/node": "^20.14.0"
}
}
"type": "module" tells Node to treat .js files as ES modules
by default, which matters once TypeScript compiles down to plain JavaScript.
Dependency versions follow semantic versioning
(MAJOR.MINOR.PATCH): a caret like ^5.5.0 allows npm to
install any 5.x.x release but never 6.0.0, since a major
version bump signals a breaking change by convention.
my-api/
├── src/
│ ├── main.ts # entry point
│ ├── routes/ # Express routers (Week 2+)
│ └── lib/ # shared utilities
├── dist/ # compiled output (gitignored)
├── package.json
├── tsconfig.json
└── .gitignore
Both work fine for this course; pnpm is worth knowing about because it stores dependencies once on disk and links them into each project (rather than copying them into every project's node_modules), which matters once you're running many Node projects side by side. Commands are nearly identical: npm install vs. pnpm install.
3. TypeScript Setup for Node
TypeScript adds a compile-time type system on top of JavaScript — it catches an
entire class of bugs (calling a function with the wrong argument type, accessing a
property that doesn't exist) before your code ever runs, at the cost of a compile
step. tsconfig.json is where you tell the compiler how to do that
compilation:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
"strict": true is the single most important setting in this file — it
enables the full set of strict type-checking rules (no implicit any,
strict null checks, and more), and every lesson in this course assumes it's on. Turning
it off makes TypeScript significantly less useful; you'd be paying the compile-step
cost without most of the safety benefit. "module": "NodeNext" paired with
"moduleResolution": "NodeNext" tells TypeScript to compile using the same
module resolution rules Node itself uses at runtime, which is why the import in
Section 2's main.ts needed an explicit .js extension even
though the source file is .ts — the compiler emits that path unchanged
into the compiled output, where the file really is .js.
npm install
npm run build # runs tsc, compiles src/ -> dist/
npm start # runs the compiled output with node
# or, for local development without a manual compile step:
npm run dev # tsx watch src/main.ts -- recompiles and restarts on save
tsx compiles and runs TypeScript directly in development without a
separate build step — you'll use npm run dev constantly through this
course, and reserve the full tsc build for what actually ships (Week
14's Docker image runs the compiled dist/ output, never tsx
itself).
4. Writing & Running Your First Script
Put the event loop and TypeScript setup together in one small script that demonstrates non-blocking I/O concretely:
import { readFile } from "node:fs/promises";
async function main(): Promise<void> {
console.log("Starting two reads at the same time...");
const start = Date.now();
// Both reads are kicked off before either has to finish --
// this is non-blocking I/O in practice, not just in theory.
const [pkg, tsconfig] = await Promise.all([
readFile("package.json", "utf-8"),
readFile("tsconfig.json", "utf-8"),
]);
console.log(`Both files read in ${Date.now() - start}ms`);
console.log(`package.json is ${pkg.length} bytes`);
console.log(`tsconfig.json is ${tsconfig.length} bytes`);
}
main().catch((err: unknown) => {
console.error("Failed:", err);
process.exitCode = 1;
});
Promise.all starts both reads concurrently rather than sequentially —
the total time is roughly the time of the slower read, not the sum of both, because
neither read blocks the thread while waiting on disk I/O. main().catch(...)
at the top level is a pattern you'll use constantly: an unhandled rejection in an
async function doesn't crash Node the way a thrown synchronous error
does, so catching it explicitly and setting process.exitCode is what
makes a script fail loudly and with the correct exit code, instead of silently.
5. Hands-on Exercise
Set up a TypeScript project and prove non-blocking I/O to yourself
Build the project skeleton this whole course will run on, and confirm the event loop model with your own timing measurements.
Requirements:
- Create a new project with the folder layout from Section 2, initialize it with
npm init -y, and installtypescript,tsxand@types/nodeas dev dependencies. - Add the
tsconfig.jsonfrom Section 3 withstrict: true, and confirmnpm run build && npm startworks end to end. - Write a script that reads 5 files concurrently with
Promise.alland logs the total elapsed time, then rewrite it to read the same 5 files sequentially with afor...ofloop andawait— log both times side by side. - Write a third version that does a deliberately expensive synchronous loop (e.g. counting to 500 million) between two
console.logtimestamps, and explain in a comment why this blocks differently than the file reads did. - Set up an npm script called
devusingtsx watchand confirm editing and saving the file triggers an automatic rerun.
Use console.time("label") and console.timeEnd("label") instead of manually computing timestamps — it's the standard, less error-prone way to measure elapsed time in Node, and you'll see the concurrent version finish in roughly the time of the single slowest read rather than the sum of all five.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why can Node handle thousands of concurrent connections on a single thread, when a heavy synchronous loop still blocks everything?
Why can Node handle thousands of concurrent connections on a single thread, when a heavy synchronous loop still blocks everything?
I/O operations are handed off to the OS or a background thread pool while the main thread moves on to other work, resuming each request via a callback only once its I/O actually completes — this is what lets many concurrent I/O-bound requests share one thread efficiently. A synchronous CPU-bound loop, by contrast, runs entirely on that same main thread with nothing to hand off, so it occupies the only thread available and every other request has to wait for it to finish.
Q2
Why does "strict": true in tsconfig.json matter so much that every lesson in this course assumes it's on?
Why does "strict": true in tsconfig.json matter so much that every lesson in this course assumes it's on?
Without strict mode, TypeScript allows implicit any types and doesn't enforce null/undefined checks, which silently disables most of the compile-time safety the type system exists to provide — you'd pay the cost of a build step without getting the corresponding benefit. With it on, the compiler catches an entire class of bugs (calling a method on a possibly-undefined value, passing the wrong shape of object) before the code ever runs.
Q3
Why does the import in a TypeScript file need a .js extension (./math.js) even though the source file is math.ts?
Why does the import in a TypeScript file need a .js extension (./math.js) even though the source file is math.ts?
With "module": "NodeNext", TypeScript emits import paths into the compiled output unchanged, and at runtime Node resolves modules against the compiled dist/ directory where the file genuinely is .js, not .ts. Writing the extension the compiled output will actually need, rather than the source extension, is what keeps the emitted JavaScript's imports resolvable by Node.
Q4
Why attach .catch(...) to a top-level async function call instead of leaving a rejected promise unhandled?
Why attach .catch(...) to a top-level async function call instead of leaving a rejected promise unhandled?
An unhandled promise rejection doesn't stop a Node script the way an uncaught synchronous throw does — it can log a warning and let the process keep running (or exit with a generic, unhelpful code, depending on the Node version), which makes real failures easy to miss silently. Explicitly catching it and setting process.exitCode guarantees the script fails loudly, with a clear error message and a correct non-zero exit code a CI pipeline can actually detect.