The previous lesson spread Escena Viva across seven workers and multiplied the server's capacity by almost five. But it left one problem untouched, and we said so bluntly: cluster does not unblock the event loop. When a worker settles down to compose the QR PDF for the 500 tickets of the Festival de Jazz de Primavera opening (evt-003, Auditorio Ribera), that process stops serving everybody for as long as the computation lasts.
In this lesson we measure that blocking with the instrumentation from Module 2, understand why threads are the right tool for CPU work (and cluster is not), and build our own ThreadPool inside Escena Viva, because creating one thread per request is worse than not using threads at all.
Contents
- The blocking, measured with
monitorEventLoopDelay - Cluster versus worker threads: the decision rule
node:worker_threads: the basic mechanism- Structured clone: what travels between threads and what it costs
- Transfer versus copy:
ArrayBuffer,SharedArrayBufferandAtomics - Escena Viva's thread:
src/workers/generate-tickets.js ThreadPool: why one thread per request is a mistake- Errors inside the thread and how they propagate
- Measurement before and after
- When NOT to use threads
- The blocking, measured with
monitorEventLoopDelay
monitorEventLoopDelayIn Module 3 we left src/utils/ticket-qr.js with { encodeQr, decodeQr }: it signs the content with HMAC and encodes it as base64url. Generating the tickets for a full session means doing that once per ticket, plus composing the PDF. It is synchronous, CPU-bound work: there is no I/O to delegate, only computation.
Let's measure it before forming an opinion. This script simulates generating the 500 opening-night tickets while a timer tries to tick every 20 ms:
'use strict';
const { monitorEventLoopDelay } = require('node:perf_hooks');
const { generateTicketBatch } = require('../src/services/ticket-pdf.js');
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
// Regular heartbeat: if the loop is free, it runs every ~20 ms.
let beats = 0;
const heartbeat = setInterval(() => { beats += 1; }, 20);
(async function measure() {
const start = process.hrtime.bigint();
await generateTicketBatch({ sessionId: 'ses-003-1', eventId: 'evt-003', quantity: 500 });
const totalMs = Number(process.hrtime.bigint() - start) / 1e6;
clearInterval(heartbeat);
histogram.disable();
console.log(`Generation: ${totalMs.toFixed(0)} ms`);
console.log(`Beats: ${beats} (expected ~${Math.round(totalMs / 20)})`);
console.log(`Maximum delay: ${(histogram.max / 1e6).toFixed(2)} ms`);
})();Result on the reference machine: Generation: 4180 ms, Beats: 3 (expected ~209), Maximum delay: 4176.44 ms.
Three beats where there should be 209. For 4.18 seconds the process ran absolutely nothing else: it answered no requests, serviced no PostgreSQL connection, processed no keep-alive. With SCHED_RR, the primary kept sending it connections —it has no idea the worker is busy— and those connections piled up in the system queue. When the worker came back, a traffic jam was waiting for it and the 99th percentile of the whole application shot up. With 7 workers the damage is 1/7 of the traffic; but if three organizers generate tickets at the same time (perfectly normal on opening night), three out of seven workers are frozen.
- Cluster versus worker threads: the decision rule
| Aspect | cluster (processes) |
worker_threads (threads) |
|---|---|---|
| Unit | Operating-system process | Thread inside the same process |
| V8 instance | One per process | One per thread (isolated) |
| Event loop | One per process | One per thread |
| Heap | Independent, never shared | Independent, but you can share memory with SharedArrayBuffer |
| Startup cost | ~40-80 ms (a full Node process) | ~10-25 ms |
| Memory cost | ~40-80 MB per process | ~5-15 MB per thread |
| Communication | Serialized IPC (JSON or advanced) | postMessage with structured clone, or shared memory |
| Share a port | Yes (the primary distributes) | No (but they can share a socket with net, advanced) |
| A crash kills... | Only that process | Only that thread, but a corrupted SharedArrayBuffer affects everyone |
| Good for | I/O concurrency: more requests at a time | CPU work: not blocking the main thread |
The decision rule, worth memorizing: cluster to scale I/O, threads to get CPU out of the way. They are not alternatives: in Escena Viva we will use both. Seven cluster workers, and inside each one a small pool of threads for the heavy work. And mind the arithmetic: 7 workers × 4 threads = 28 CPU threads on an 8-core machine is overselling. We will come back to it in section 7.
node:worker_threads: the basic mechanism
node:worker_threads: the basic mechanismThe module exposes the essentials in very few pieces:
| Element | Where it lives | What for |
|---|---|---|
new Worker(path, options) |
Main thread | Creates the thread and runs the given file |
workerData |
Worker thread | Initial data, cloned at startup |
parentPort |
Worker thread | Communication port with whoever created it |
isMainThread / threadId |
Both | Which thread we are on and with which identifier |
MessageChannel / MessagePort |
Both | Extra channels between any two threads |
A minimal example, with the events that matter:
'use strict';
const path = require('node:path');
const { Worker } = require('node:worker_threads');
function runInThread(filePath, data) {
return new Promise((resolve, reject) => {
const thread = new Worker(path.join(__dirname, filePath), { workerData: data });
thread.on('message', (result) => resolve(result));
// 'error': uncaught exception inside the thread. 'online': the thread
// is already running JavaScript (useful to measure startup cost).
thread.on('error', (error) => reject(error));
thread.on('exit', (code) => {
if (code !== 0) reject(new Error(`The thread ended with code ${code}`));
});
});
}And the thread's file, where workerData arrives already cloned (it is a copy, not a reference):
'use strict';
const { parentPort, workerData, threadId } = require('node:worker_threads');
const sum = workerData.numbers.reduce((total, value) => total + value, 0);
parentPort.postMessage({ thread: threadId, sum });Two important nuances. First, postMessage does not end the thread; the thread lives until it runs out of pending work or somebody calls terminate(), and it can keep receiving tasks through parentPort.on('message', ...). This is precisely what allows reusing it in a pool. Second, each thread loads its own modules: if generate-tickets.js does a require of Sequelize, that require runs once per thread, with its startup and memory cost.
- Structured clone: what travels between threads and what it costs
Messages between threads do not share memory by default: they are copied with the structured clone algorithm (the same one used by postMessage in the browser and by structuredClone). It is more capable than JSON.stringify, but it has strict limits.
| Type | Does it travel? | Note |
|---|---|---|
Primitives (null, undefined, BigInt included) |
Yes | |
| Plain objects and arrays | Yes | Circular references included |
Date, RegExp |
Yes | Preserved as such, not as strings |
Map, Set |
Yes | A clear advantage over JSON |
Buffer, TypedArray, ArrayBuffer |
Yes | Copied, unless transferred |
Error |
Yes | message, name and stack are preserved |
| Functions | No | DataCloneError |
| Classes (their identity) | No | A plain object arrives, with no prototype and no methods |
Getters, symbols, WeakMap |
No | Silently lost or they fail |
| Connections, sockets, fs handles | No | They make no sense outside their thread |
The direct consequence for Escena Viva: you cannot send a Ticket or an Order instance and expect it to keep its methods. Our domain (src/domain/) uses classes; when crossing the thread boundary you must serialize to plain data and rebuild on the other side, which is why we will design it that way: the thread receives primitive data and returns primitive data. Copying is not free either: cloning 500 ticket objects is on the order of a millisecond, but cloning an 8 MB PDF as a Buffer is on the order of tens. If the result is large, transfer it instead of copying it.
- Transfer versus copy:
ArrayBuffer, SharedArrayBuffer and Atomics
ArrayBuffer, SharedArrayBuffer and AtomicspostMessage accepts a second argument with the list of transferable objects: parentPort.postMessage({ data: arrayBuffer }, [arrayBuffer]). A transferred ArrayBuffer is not copied, it changes owner, and the sending thread is left with a detached buffer (byteLength === 0). It is the right way to return a PDF of several megabytes, and we will see it applied in section 6.
SharedArrayBuffer goes one step further: the same memory is visible from several threads at once, with no copy and no transfer. It is the tool for shared counters, such as the number of tickets generated on opening night:
'use strict';
// In the main thread: 2 shared 32-bit integers, sent to every thread
// in workerData WITHOUT being copied.
// counters[0] = tickets generated, counters[1] = errors.
const counters = new Int32Array(new SharedArrayBuffer(8));
// Atomics.add reads, adds and writes without another thread sneaking in
// halfway. With counters[0] += 1 we would lose increments.
const recordGeneratedTicket = (c) => Atomics.add(c, 0, 1);
// Atomics.load guarantees we see the most recent value published by any
// thread, with no reordering by the compiler or the CPU.
const readTotal = (c) => Atomics.load(c, 0);
module.exports = { counters, recordGeneratedTicket, readTotal };A serious warning. Until now, in single-threaded JavaScript, memory race conditions simply did not exist: between two lines of your function nobody could modify your variables. SharedArrayBuffer opens that door. counters[0] += 1 is three operations (read, add, write) and two threads can interleave them and lose increments. That is why Atomics exists (add, load, store, compareExchange, wait, notify). Use it always to touch shared memory, and use shared memory only when you genuinely need it: for structured data, cloning is slower but infinitely safer.
- Escena Viva's thread:
src/workers/generate-tickets.js
src/workers/generate-tickets.jsThe thread receives the plain data for a batch of tickets, generates each one's signed QR reusing encodeQr from Module 3, composes the PDF and returns the result by transferring the buffer.
'use strict';
const { parentPort } = require('node:worker_threads');
const { encodeQr } = require('../utils/ticket-qr.js');
const { composeTicketsPdf } = require('../services/pdf.js');
// Generates the QR and the PDF for a batch of tickets. Pure CPU work.
function generateBatch({ orderId, eventTitle, venueName, sessionDate, tickets }) {
const ticketsWithQr = tickets.map((ticket) => ({
code: ticket.code, // format EV-2026-004871
seat: ticket.seat,
priceCents: ticket.priceCents,
// encodeQr signs with HMAC and returns base64url (Module 3).
qr: encodeQr({ code: ticket.code, sessionId: ticket.sessionId }),
}));
const pdfBuffer = composeTicketsPdf({
orderId, eventTitle, venueName, sessionDate, tickets: ticketsWithQr,
});
return { pdfBuffer, generated: ticketsWithQr.length };
}
// The thread stays alive listening for tasks: that is how the pool reuses it.
parentPort.on('message', ({ taskId, request }) => {
try {
const { pdfBuffer, generated } = generateBatch(request);
const arrayBuffer = pdfBuffer.buffer.slice(
pdfBuffer.byteOffset, pdfBuffer.byteOffset + pdfBuffer.byteLength
);
parentPort.postMessage(
{ taskId, ok: true, result: { pdf: arrayBuffer, generated } },
[arrayBuffer] // transfer with no copy
);
} catch (error) {
// We never let the exception kill the thread: we return it as data.
parentPort.postMessage({
taskId, ok: false,
error: { message: error.message, name: error.name, stack: error.stack },
});
}
});Notice the design decision: the thread does not throw exceptions upwards, it turns them into responses. An uncaught throw would fire the error event and kill the thread, forcing the pool to create another one. Catching inside and answering with ok: false keeps the thread reusable.
ThreadPool: why one thread per request is a mistake
ThreadPool: why one thread per request is a mistakeWe measured the startup cost of a thread that does a require of our code: between 18 and 40 ms. If the task lasts 4,000 ms, that cost is noise. But for small batches —a single ticket bought by Lucía for ses-001-1— the task lasts 12 ms and startup costs three times as much as the work. Worse still: 50 simultaneous requests would create 50 threads, that is 50 V8 instances competing for 8 cores, with hundreds of megabytes of memory and a collapse from context switching.
The solution is a pool: a fixed, small number of threads created at startup, a task queue, and reuse.
'use strict';
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { Worker } = require('node:worker_threads');
const WORKER_PATH = path.join(__dirname, 'generate-tickets.js');
class ThreadPool {
// A small size by default: CPU work does not scale beyond the cores,
// and we coexist with the cluster workers (10-01).
constructor({ size, workerPath = WORKER_PATH, timeoutMs = 30_000 } = {}) {
this.size = size || Math.max(1, Math.floor(os.availableParallelism() / 2));
this.workerPath = workerPath;
this.timeoutMs = timeoutMs;
this.threads = []; // every live thread
this.idle = []; // threads with no task assigned
this.queue = []; // tasks waiting for a thread
this.pending = new Map(); // taskId -> { resolve, reject, timer }
this.closed = false;
for (let i = 0; i < this.size; i += 1) this.createThread();
}
createThread() {
const thread = new Worker(this.workerPath);
thread.currentTask = null;
thread.on('message', (message) => {
const pending = this.pending.get(message.taskId);
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(message.taskId);
if (message.ok) pending.resolve(message.result);
else pending.reject(Object.assign(new Error(message.error.message), {
name: message.error.name, stack: message.error.stack,
}));
}
this.releaseThread(thread);
});
// The thread died from an uncaught exception: we reject its current
// task and replace it to keep the pool at its size.
thread.on('error', (error) => {
this.rejectTaskOf(thread, error);
this.removeThread(thread);
if (!this.closed) this.createThread();
});
thread.on('exit', (code) => {
if (code !== 0 && !this.closed) { this.removeThread(thread); this.createThread(); }
});
this.threads.push(thread);
this.idle.push(thread);
}
removeThread(thread) {
this.threads = this.threads.filter((other) => other !== thread);
this.idle = this.idle.filter((other) => other !== thread);
}
rejectTaskOf(thread, error) {
const pending = thread.currentTask && this.pending.get(thread.currentTask);
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(thread.currentTask);
pending.reject(error);
}
thread.currentTask = null;
}
// Once free, the thread takes the next task from the queue if there is one.
releaseThread(thread) {
thread.currentTask = null;
const next = this.queue.shift();
if (next) this.assign(thread, next);
else this.idle.push(thread);
}
assign(thread, task) {
thread.currentTask = task.taskId;
thread.postMessage({ taskId: task.taskId, request: task.request });
}
// Public API: returns a promise with the thread's result.
run(request) {
if (this.closed) return Promise.reject(new Error('The pool is closed'));
const taskId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(taskId);
reject(new Error(`Task ${taskId} exceeded ${this.timeoutMs} ms`));
}, this.timeoutMs);
this.pending.set(taskId, { resolve, reject, timer });
const thread = this.idle.pop();
if (thread) this.assign(thread, { taskId, request });
else this.queue.push({ taskId, request }); // all busy: wait your turn
});
}
// status() exposes { size, idle, queued } for the health endpoint.
// close() marks the pool as closed and calls terminate() on every thread,
// and hooks into the graceful shutdown from M6.
}
module.exports = { ThreadPool };About the pool size: for purely CPU-bound work, more threads than cores adds nothing but context switching. And since each cluster worker has its own pool, the real calculation is clusterWorkers × poolSize ≤ cores. With 7 workers on 8 cores, the pool should be 1 or 2 threads. This tension —cluster and threads competing for the same cores— is why many architectures end up taking the heavy work out of the web server entirely, into a separate process with a queue: lesson 10-03. In production, moreover, the piscina library does all this and more (cancellation with AbortSignal, thread recycling after N tasks, queue limits, metrics); we wrote our own to understand what it does inside.
The application service ends up this simple:
'use strict';
// A single pool per process, created at startup. Never one per request.
function createTicketService({ threadPool }) {
return {
async generateOrderPdf(orderData) {
const { pdf, generated } = await threadPool.run(orderData);
// 'pdf' arrives as a transferred ArrayBuffer; we wrap it without copying.
return { buffer: Buffer.from(pdf), generated };
},
};
}
module.exports = { createTicketService };It fits without friction into the Module 9 refactor: controllers are factories with injected dependencies, so the pool is injected as one more dependency and in tests it is replaced by a Sinon double.
- Errors inside the thread and how they propagate
| Situation in the thread | What happens | How the pool handles it |
|---|---|---|
throw caught by our try/catch |
It answers { ok: false, error } |
Rejects the promise; the thread stays alive |
Uncaught throw |
error event in the parent; the thread dies |
Rejects the current task, removes and recreates the thread |
| Unhandled promise rejection | error event (the default policy) |
Same as the previous one |
process.exit() inside the thread |
exit event with a code |
Recreates the thread; the task is orphaned unless we reject it |
| Infinite loop | Nothing: the thread never answers | The timeoutMs fires and it is rejected; a terminate() is advisable |
| Out of memory | The thread dies (or the whole process) | Limit the batch sizes |
One nuance about the timeout: our run rejects the promise, but the thread keeps computing. For a genuine infinite loop you have to call terminate() and recreate the thread (exercise 2).
- Measurement before and after
Scenario: Festival de Jazz opening night, with a base load of 500 requests per second against the catalog and, every 2 seconds, an organizer requesting the PDF for a batch of 500 tickets.
| Metric | Without threads (synchronous) | With ThreadPool (2 threads) |
|---|---|---|
| Maximum event loop delay | 4,176 ms | 11 ms |
| Catalog p50 latency | 1,240 ms | 18 ms |
| Catalog p99 latency | 6,810 ms | 74 ms |
| Requests with 503 / timeout | 217 | 0 |
| Duration of the batch generation | 4,180 ms | 4,390 ms |
Read that last row carefully: the generation takes slightly longer (4,390 versus 4,180 ms), because the input request has to be cloned, the result transferred and the pool coordinated. Threads do not speed up the heavy task; what they do is get it out of everyone else's way. The catalog p99 drops from 6.8 seconds to 74 milliseconds, and that is the only figure that matters to somebody trying to buy a ticket while the batch is generated.
- When NOT to use threads
Threads have a real cost (memory, startup, complexity, serialization). Do not use them if:
- The task is I/O. Reading a file, querying PostgreSQL, calling the currency API: that is already asynchronous and blocks nothing. Putting a SQL query in a thread does not speed it up; it only adds a data copy and a hop between threads. This is the most frequent misunderstanding.
- The task is trivial. If the work lasts 2 ms, the cloning and handover cost more than doing it on the spot. Rough threshold: below ~10-20 ms of CPU it is not worth it.
- The task can be chunked. In Module 2 we saw how to split a long loop with
setImmediateto hand control back to the loop between chunks. If the work can be cut cleanly and is not enormous, chunking is simpler than a pool.
'use strict';
// The Module 2 alternative: chunk instead of using threads. It processes
// the batch in blocks of 25 and yields control between blocks, so the
// server answers requests in between one block and the next.
function processInChunks(items, transform, chunkSize = 25) {
return new Promise((resolve) => {
const results = [];
let index = 0;
(function nextChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index += 1) results.push(transform(items[index]));
if (index < items.length) setImmediate(nextChunk);
else resolve(results);
})();
});
}Chunking cuts the maximum loop delay down to the duration of one chunk (about 200 ms in our case), which is already acceptable for many applications, and it costs neither a thread nor a megabyte. The trade-off is that the total task takes longer and still consumes the one and only main thread.
- The task is genuinely long. If generating and e-mailing the 500 opening-night tickets takes minutes, neither a thread nor chunking is the answer: the HTTP request should not wait at all. You have to take the work out of the process and enqueue it, answer
202 Acceptedwith an identifier, and let the client poll the status. That is the subject of the next lesson.
Common Mistakes and Tips
- Creating a
Workerper request. The most expensive mistake. Always a pool, created when the process starts. - Sending domain class instances. They arrive as plain objects with no methods: serialize to data and rebuild on the other side.
- Putting I/O in a thread. It improves nothing: the I/O was already asynchronous.
- Using
SharedArrayBufferwithoutAtomics, or forgetting that a transferredArrayBufferis left empty in the sender. - Not setting a timeout on tasks. A hung thread holds its slot in the pool forever and ends up stopping all generation.
- Tip: expose
pool.status()in your health endpoint. A queue that grows without stopping tells you the pool is undersized or that you need to enqueue outside the process. - Tip: always measure with
monitorEventLoopDelaybefore and after. If the delay does not drop, threads were not the problem.
Exercises
Exercise 1: demonstrate the blocking and measure it
Write a script that starts a minimal HTTP server with a /block route that computes 500 SHA-256 hashes of 100,000 iterations each, synchronously, and a /ping route that answers { ok: true }. With autocannon against /ping, measure the p99 at rest and while /block is running. Record the loop delay with monitorEventLoopDelay.
Exercise 2: add terminate to the timeout
Modify ThreadPool so that, when timeoutMs expires, besides rejecting the promise it calls terminate() on the blocked thread and replaces it. Check the behavior with a thread that runs while (true) {}.
Exercise 3: shared counter with Atomics
With 4 threads each incrementing the same Int32Array over a SharedArrayBuffer 100,000 times, compare the result using counters[0] += 1 versus Atomics.add(counters, 0, 1). Explain the difference.
Solutions
Exercise 1. At rest, /ping gives a p99 of 2-4 ms. During /block, every request to /ping piles up and the p99 climbs to several seconds: exactly the duration of the synchronous computation. The histogram shows a max almost identical to that duration, because the loop did not turn even once. The correct reading is that /ping has no problem at all: the problem is a noisy neighbor on the same thread. It is this lesson's entire argument in 30 lines of code.
Exercise 2. Inside run's setTimeout, you locate the thread with this.threads.find((t) => t.currentTask === taskId), remove it from the pool, call await hungThread.terminate() —which kills the thread even if it is in a loop— and create a new one before rejecting the promise. With while (true) {} in the thread, without terminate() the pool slot is lost forever and after N hung tasks the pool is unusable. With terminate(), the pool recovers on its own. Note: terminate() kills the thread immediately and no graceful shutdown is possible inside it.
Exercise 3. With counters[0] += 1 the expected total is 400,000 but the observed one is around 120,000-250,000, and it varies on every run. The reason is that += is three steps (read, add, write) and two threads can read the same value and write the same result, losing one increment. With Atomics.add the total is exactly 400,000, always: the operation is indivisible at the CPU level. The moral is twofold: shared memory introduces a class of bugs that did not exist in JavaScript, and those bugs are non-deterministic, so your Module 9 tests can pass a thousand times and fail in production on opening night.
Conclusion
We have turned a 4.18-second block into an 11-millisecond delay. We measured the problem with monitorEventLoopDelay, drew a clear line between when cluster applies (I/O concurrency) and when threads do (CPU work), understood what travels between threads through structured clone and what does not, learned to transfer instead of copy and to share memory with Atomics without opening the door to race conditions, and built src/workers/generate-tickets.js and src/workers/pool.js with a queue, reuse, a timeout and error recovery. We also learned the limits: threads do not speed the task up, they only move it aside; they are useless for I/O; they are not worth it for trivial work; and they compete for the same cores as the cluster workers, which forces you to size things sensibly.
And one piece is still out of place. Generating the PDF no longer blocks, but the Auditorio Ribera organizer is still waiting four and a half seconds with the HTTP request open for it to finish. If those 500 tickets also have to be e-mailed, the wait becomes absurd. The answer is not a faster thread: it is not waiting. In the next lesson, Caching and Job Queues with Redis, we will move the heavy work out of the process, answer 202 Accepted with a job identifier, cache the catalog that today is recomputed on every request, and along the way settle the debt lesson 10-01 left behind: sessions and rate limits shared across every worker.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
