Keentune

Node.js curriculum

24 chapters
·
215 concepts
·
free
Everything the adaptive question bank can teach and test in Node.js, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Node.js guide
A free 17-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. The runtime: what Node adds to JavaScript
V8 executes the JavaScript; libuv supplies the event loop, the thread pool and the OS I/O bindings
your JavaScript runs on one thread, while fs, dns, zlib and crypto work is offloaded to a pool that defaults to four threads
a synchronous CPU loop or a readFileSync in a handler stalls every other pending connection
there is no window or document; globalThis is the portable spelling of the global object
a write to globalThis is visible in every module, which is why it is a leak, not a feature
node:fs names a builtin unambiguously and cannot be shadowed by an installed package called fs
fetch, URL, TextEncoder, AbortController and structuredClone are globals, not imports
B. The event loop, timers and scheduling
each turn runs timers, pending callbacks, poll, check and close handlers in that fixed order
with no timers or immediates due, the loop parks in poll waiting on the OS, which is how an idle server costs nothing
inside an I/O callback setImmediate always wins; from the main module the order is genuinely nondeterministic
the process.nextTick queue drains before promise microtasks and before the loop advances a phase
a nextTick callback that re-queues itself starves I/O forever without ever looking blocked
the microtask queue is drained to empty between callbacks, not one entry per turn
setTimeout(fn, 100) promises "not before 100 ms"; a busy tick pushes it later
a delay below 1 or above 2147483647 is coerced to 1 ms
a pending timer, server or socket holds the process open until .unref() releases it
node:timers/promises gives an awaitable sleep that accepts an AbortSignal
C. The process: arguments, environment and lifecycle
argv[0] is the node binary and argv[1] the script, so user arguments start at index 2
util.parseArgs parses options and -- passthrough without a dependency
everything in process.env is a string, so the value "false" is truthy
0 means success; a non-zero code, an uncaught throw and an unhandled rejection all mean failure
process.exit() abandons queued asynchronous writes, silently losing log lines
set process.exitCode and let the loop drain instead of tearing the process down
SIGTERM and SIGINT can be trapped for graceful shutdown; SIGKILL cannot be caught at all
beforeExit may schedule more asynchronous work, exit handlers must be entirely synchronous
stdout writes are synchronous to a file and a TTY but asynchronous to a pipe
process.cwd() is wherever node was launched, and chdir moves it under you
D. CommonJS modules
require reads, compiles and evaluates the file on the spot, blocking the loop
a module body runs once per resolved path; every later require returns the same exports object
reassigning exports only rebinds a local name; only module.exports is what callers receive
a cycle hands back the half-filled exports object instead of erroring, so a value can be undefined at import time
a bare name walks node_modules upward from the requiring file, not from the cwd
.js, .json, .node, then the directory's main or index.js
__dirname and __filename exist only in CommonJS, which is why ported code breaks in ESM
every file is wrapped in a function taking exports, require, module, __filename, __dirname, so top-level this is module.exports
from Node 22 require() can load an ES module, but throws if that graph uses top-level await
E. ESM, package.json and module resolution
"type": "module" makes .js files ESM, and .mjs/.cjs override it per file
every static import is resolved and evaluated before the importing module's first statement runs
import() returns a promise, works inside CommonJS, and is the supported bridge into ESM
the ESM resolver never guesses .js or an index file
import.meta.url replaces __filename, with import.meta.dirname added later as the directory form
legal only in ESM, and it delays every module that imports it
Node static-analyzes a CommonJS file for named exports; anything assigned dynamically is reachable only via the default
an "exports" map blocks deep imports of files it does not list
"import", "require", "node" and "default" select a different file per consumer, in declaration order
an imported binding tracks the exporter's later reassignment; a required value is a snapshot
F. EventEmitter and the event pattern
listeners fire synchronously in the order they were added, and once detaches after the first call
emitting 'error' with no listener attached throws and crashes the process
emit returns after every listener has run, and returns whether there were any
the eleventh listener on one event name prints a leak warning, which is a warning and not a limit
an inline arrow function cannot be removed later, which is the classic emitter leak
events.once(emitter, name) awaits one event and rejects if 'error' fires first
Node emitters pass an arbitrary argument list; the web EventTarget passes a single event object
G. Errors and asynchronous failure
the callback convention puts the error in the first parameter and the value second
a try around an asynchronous call has already returned by the time the callback throws
after it fires the process state is undefined; log and exit rather than continue
since Node 15 an unhandled promise rejection terminates the process by default
err.code such as ENOENT or ERR_MODULE_NOT_FOUND is the stable contract; the message text is not
a syscall failure carries errno, syscall, path and dest alongside the code
new Error(msg, { cause }) keeps the original error instead of stringifying it into the message
Promise.any and a multi-address connect report several failures in one error's errors array
an await preserves the calling frames, while a raw callback boundary loses them
Promise.all abandons the rest on the first rejection, allSettled reports every outcome
H. Buffers and binary data
a Buffer is a Uint8Array subclass, so every typed-array method applies plus Node's encoding helpers
allocUnsafe skips zero-filling for speed and can hand back whatever was in that memory
utf8 is the default, and latin1, hex and base64 reinterpret the very same bytes
one emoji is one or two UTF-16 units but four bytes, so the two lengths disagree
a slice is a view; writing through it mutates the parent buffer
Buffer.concat allocates a new buffer, copy writes into one you already own
readUInt32BE versus readUInt32LE makes byte order part of the code, not the platform
decoding chunk by chunk splits a multi-byte character unless StringDecoder holds the remainder
the URL-safe alphabet swaps + and / and drops padding, so the two forms are not interchangeable
I. Streams and backpressure
Readable, Writable, Duplex and Transform, and which one a job needs
attaching a 'data' listener starts the flow; read() pulls on demand
write() returning false means stop producing until 'drain' fires
the buffered amount that triggers backpressure, counted in bytes or, in object mode, in items
a failed source leaves the destination open, which is a file descriptor leak
stream.pipeline destroys every stage on failure and reports the first error
object mode carries arbitrary values instead of bytes and counts items toward the watermark
_transform may push zero or many chunks per input, and _flush emits the tail
for await (const chunk of readable) consumes with backpressure applied automatically
Readable.from turns any iterable or async generator into a stream
end() finishes the write and flushes; destroy() tears down immediately and discards buffered data
'end' when reading finishes, 'finish' when writing flushes, 'close' after teardown
a readable is one-shot, so a second consumer attached after the data flowed gets nothing
J. Web streams and cross-standard interop
WHATWG ReadableStream is what fetch returns; node:stream is Node's own older type
Readable.fromWeb and Readable.toWeb bridge the two families without buffering the whole body
getReader() locks the stream until released, so a second reader throws
tee() duplicates a stream and queues chunks for whichever branch reads slower
TransformStream plus pipeThrough is the web pipeline, with its own queuing strategy
K. The file system
callback, fs.*Sync, and node:fs/promises expose the same operations with different calling conventions
readFile holds the whole file in memory, so large inputs need a stream
without an encoding option every read hands back a Buffer, not a string
fs.open leaks a descriptor unless closed, including on the error path
checking then acting is a TOCTOU bug; open or read and handle ENOENT instead
{ recursive: true } creates missing parents and does not fail when the directory already exists
Dirent entries report the type, avoiding a stat call per file
lstat describes the symlink itself while stat follows it to the target
rename is atomic within one filesystem and fails with EXDEV across a mount boundary
fs.watch coalesces, duplicates and names events differently per OS, so it is not a reliable log
'a' appends, 'wx' refuses to clobber, and mode sets the created file's permission bits
L. Paths, URLs and name handling
join concatenates segments, resolve walks right to left until it has an absolute path
path.sep and the path.win32/path.posix variants instead of a hard-coded slash
splitting into dir, name and extension without string surgery, including the dotfile edge case
normalize resolves .. lexically, without touching the filesystem
a safe "is inside this directory" test uses path.relative, not a string prefix comparison
an ESM import.meta.url is a URL string and must be converted before fs will accept it
new URL throws on a relative reference unless a base is supplied
URLSearchParams escapes and repeats keys correctly where string concatenation corrupts them
M. HTTP servers and clients
the body arrives as chunks and nothing is parsed or buffered for you
req.headers keys are lower case, and set-cookie is the one that arrives as an array
writing a body then calling writeHead fails with ERR_HTTP_HEADERS_SENT
a response that is never ended holds the socket until a timeout, not until garbage collection
connection reuse belongs to the Agent, and its pool size is what caps outbound concurrency
headersTimeout, requestTimeout and keepAliveTimeout each guard a different kind of stall
an ignored request body must be consumed or destroyed before the connection can be reused
omitting Content-Length switches the response to chunked transfer encoding
the global fetch is undici with its own pool, and it ignores the proxy environment variables by default
a Response body can be read once; clone() before a second read
N. TCP, UDP, TLS and DNS
a net.Socket is a stream, so the backpressure rules apply unchanged
TCP is a byte stream, so application message boundaries must be framed by you
small writes are coalesced by default, which adds latency until setNoDelay disables it
listening on 0.0.0.0 exposes the port to the network; 127.0.0.1 does not
binding port 0 asks the OS for any free port, which is how tests avoid collisions
dgram messages keep their boundaries but may be dropped, duplicated or reordered
rejectUnauthorized: false turns off the certificate check that makes TLS meaningful
dns.lookup uses the OS resolver on the thread pool; dns.resolve queries a DNS server directly
O. Child processes
spawn streams output while exec buffers it and fails once maxBuffer is exceeded
passing an argument array avoids a shell entirely, which is the fix for interpolated input
fork starts another Node process with a send/'message' channel already wired up
each descriptor can be 'pipe', 'inherit', 'ignore' or a stream
a killed child reports a signal name and a null exit code
a child whose stdout is piped but never read blocks once the OS pipe buffer fills
outliving the parent needs both detached and an unref on the handle
no shell involved, and the arguments stay an explicit list
P. worker_threads and multi-process scaling
I/O is already concurrent, so another thread only helps computation
each worker gets its own V8 isolate, event loop and module registry, so nothing is shared implicitly
postMessage copies by the structured clone algorithm, so functions and class identity do not survive
an ArrayBuffer can be transferred instead of copied, which detaches it in the sender
the only genuinely shared memory, and it needs Atomics to be read or written safely
the startup payload, cloned once when the worker is created
spawning a worker costs milliseconds and megabytes, so pool them rather than making one per task
a MessagePort pair connects two workers directly and is itself transferable
an uncaught error inside a worker arrives as an 'error' event on the parent's handle
cluster forks one process per core and the primary distributes connections to a shared listener
an in-memory cache or session store is per worker, so it must move out of process
nothing respawns a dead cluster worker, and a staggered restart is what makes a reload zero-downtime
Q. Cancellation and asynchronous context
one controller owns the signal that every cancellable API accepts
an aborted signal never resets, so a retry needs a fresh controller
AbortSignal.timeout(ms) is a self-aborting signal with no controller to keep alive
combining a user cancel and a deadline into a single signal
cancellation rejects with an error named AbortError and code ABORT_ERR, which must not be logged as a failure
a server request exposes a signal that fires when the client disconnects, so the work can stop
a value that follows an asynchronous call chain without being threaded through every argument list
a callback captured before run() resumes outside the store and sees no context
R. Cryptography
a digest is one-way, so there is no decrypt for a hash
scrypt or a comparable work-factored KDF, never a bare SHA-256
a unique salt is what defeats precomputed tables, and it need not be secret
comparing secrets with === leaks length and matching prefix through timing
Math.random is not cryptographic; randomBytes and randomUUID are
v7 is time-ordered so it keeps index locality that a random v4 primary key destroys
an HMAC binds a secret key to the digest, which is what makes a signature check meaningful
repeating an IV or nonce under the same key breaks the cipher's guarantees
AES-GCM produces an auth tag that must be verified, and ignoring it removes the integrity guarantee
crypto.subtle is the promise-based standard API, node:crypto the older synchronous one
S. The permission model and hardening
--permission denies filesystem, child-process, worker and native-addon access until explicitly granted
--allow-fs-read and --allow-fs-write take paths, so access is granted per directory rather than wholesale
the restriction applies to dependencies too, which is the entire point
process.permission.has() lets code ask before it triggers ERR_ACCESS_DENIED
the model shrinks blast radius; it is not an isolation boundary against native code
an install script and every transitive dependency execute with your full privileges
command-line arguments are visible in the process table, so a token belongs in the environment or a file
T. Diagnostics, profiling and memory
performance.mark and measure use a monotonic clock that a wall-clock Date.now difference does not
monitorEventLoopDelay quantifies saturation, which is the number that predicts latency
named publish points that cost nothing while no subscriber is attached
--inspect opens the DevTools protocol and --inspect-brk waits for a debugger to attach
comparing two snapshots taken under load is how a retained object is identified
short-lived objects are collected cheaply; promotion to the old generation is what causes pauses
the V8 heap limit is a flag, not the machine's memory, and a container can be killed well before it
resident memory includes Buffers and native allocations that never appear in the V8 heap
an unbounded Map, a listener never removed, and a closure holding a large Buffer
--cpu-prof samples the stack so the hot function is measured rather than guessed
U. Testing with node:test
node --test discovers and runs test files with no dependency installed
an unawaited nested test is reported as incomplete rather than failing loudly
node:assert/strict makes equality deep and type-sensitive by default
an asynchronous failure needs await assert.rejects, because a try around a non-awaited call catches nothing
the built-in mock replaces a method and restores the original afterwards
mock.timers advances setTimeout deterministically instead of sleeping
each test file runs in its own process, and concurrency inside a file is opt-in
tests no longer inherit file order, which exposes hidden coupling between them
coverage is a runner flag, not a separate instrumentation tool
V. The CLI, flags and configuration
node --run executes a package.json script directly, skipping a package manager's startup cost
node --watch restarts on change, and --watch-path narrows what it watches
--env-file loads a dotenv file into process.env without a dependency
NODE_OPTIONS applies flags to this process and every Node child it spawns
NODE_ENV means nothing to the runtime itself; only libraries read it
runtime flags must precede the script path, and anything after it belongs to the script
--conditions adds a custom export condition consulted during resolution
--import and --require run code before the entry point, which is how instrumentation attaches
W. Modern Node: TypeScript, SQLite, packaging and releases
Node runs a .ts file by erasing the annotations, with no downlevelling of syntax
enums, namespaces and parameter properties emit code, so they need a compiler or the transform flag
stripping never reports a type error, so a checker still belongs in CI
node:sqlite puts a synchronous embedded database in core, with no native build step
bound parameters, not interpolated strings, are what makes an embedded query safe
a SEA injects the script into a copy of the node binary to ship one file
the global deep-copies cycles, Maps, Dates and typed arrays that a JSON round trip destroys
one major per April, and every line now becomes LTS; the odd/even rule is retired
production targets Active LTS, while Current carries features that can still change
documentation-only, then a runtime warning, then removal, and which one --throw-deprecation surfaces
X. The rest of the standard toolkit
converting between the callback convention and promises in either direction
console.log renders nested objects only two levels deep before printing [Object]
no levels, no structure, and it splits output across stdout and stderr
Brotli compresses smaller for more CPU, and both have streaming forms that avoid buffering
os.cpus() describes the machine, while availableParallelism respects the affinity a container sets
node:vm isolates the scope, not the capabilities, so untrusted code still escapes
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Node.js practice
Also on your phone
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.
© 2026 SportaApp LLC