"Learn the foundation of distributed systems by passing messages between a Node.js main thread and worker thread."
When we use RabbitMQ, Kafka, or another message broker, it is easy to focus on the API and miss the fundamental idea underneath:
One component owns some state, and other components communicate with it by sending messages.
In this first project of my Node.js Distributed Systems Lab, we will build that idea using only Node.js core APIs.
We will not use a framework, database, or message broker. We will use two small JavaScript files and the built-in worker_threads module.
What we will build
Our program has two parts:
- The main thread starts a worker and sends commands.
- The worker thread owns a counter and processes those commands.
The main thread will ask the worker to:
- Increment the counter by
1. - Increment it by
4. - Return the current count.
- Stop.
The important rule is that the main thread never modifies the counter directly. It can change the counter only by sending a message.
Project structure
01-message-passing/
├── main.js
└── worker.js
This example requires no npm dependencies. Node.js 20 or newer is enough.
The main thread
Create main.js:
const path = require("node:path");
const { Worker } = require("node:worker_threads");
const workerPath = path.join(__dirname, "worker.js");
const worker = new Worker(workerPath);
worker.on("message", (message) => {
console.log("Main received:", message);
});
worker.on("error", (error) => {
console.error("Worker error:", error);
});
worker.on("exit", (code) => {
console.log(`Worker exited with code ${code}`);
});
worker.postMessage({
type: "increment",
amount: 1,
});
worker.postMessage({
type: "increment",
amount: 4,
});
worker.postMessage({
type: "get-count",
});
worker.postMessage({
type: "stop",
});
Let us look at the important parts.
const worker = new Worker(workerPath);
This starts worker.js in a separate worker thread.
worker.postMessage({
type: "increment",
amount: 1,
});
postMessage() does not directly call a function in worker.js. It sends a message to the worker's message port. The worker receives it asynchronously.
worker.on("message", (message) => {
console.log("Main received:", message);
});
The worker can also send messages back. The main thread listens for those messages using the message event.
The worker thread
Create worker.js:
const { parentPort } = require("node:worker_threads");
let count = 0;
parentPort.on("message", (message) => {
if (message.type === "increment") {
count += message.amount;
parentPort.postMessage({
type: "count-changed",
count: count,
});
}
if (message.type === "get-count") {
parentPort.postMessage({
type: "current-count",
count: count,
});
}
if (message.type === "stop") {
parentPort.close();
}
});
The counter is declared inside the worker:
let count = 0;
The worker owns this state. The main thread does not import or share the variable.
The worker listens for messages from its parent:
parentPort.on("message", (message) => {
// Handle the message.
});
When it receives an increment message, it updates its state and reports the new value:
count += message.amount;
parentPort.postMessage({
type: "count-changed",
count: count,
});
The object shapes form a small message protocol. Both sides must agree on what type, amount, and count mean.
Running the project
Run the main file:
node main.js
You should see:
Main received: { type: 'count-changed', count: 1 }
Main received: { type: 'count-changed', count: 5 }
Main received: { type: 'current-count', count: 5 }
Worker exited with code 0
The messages sent through the same port are handled in order. The worker first adds 1, then adds 4, and only then handles get-count.
What this example teaches
1. State ownership
Only the worker owns and modifies count. Other parts of the program must send messages to request a change.
This is easier to reason about than allowing several components to mutate the same state directly.
2. Asynchronous communication
The main thread sends a message and continues executing. The worker processes the message separately and may respond later.
This is the same mental model used by many queue-based and distributed systems, although the transport mechanism will be different.
3. Message protocols
The sender and receiver need a shared agreement about message structure.
For example:
{
type: "increment",
amount: 4
}
As a system grows, designing and versioning these message formats becomes an important engineering problem.
4. Isolation
Worker threads execute JavaScript separately. If we avoid shared memory, they can coordinate using messages instead of directly sharing mutable objects.
Two easy mistakes
While building this example, I encountered two simple mistakes that show why a clear protocol matters.
Mismatched property names produce NaN
If main.js sends this:
{
type: "increment",
amount: 4
}
but worker.js reads message.value, the value is undefined:
count += message.value;
Adding undefined to a number produces NaN. The property must have the same name on both sides.
parentPort.stop() does not exist
To close the worker's message port, use:
parentPort.close();
There is no parentPort.stop() method.
Is this a distributed system?
No—not yet.
The main thread and worker thread run inside one Node.js process on one machine. This project demonstrates the message-passing foundation without first adding networking, serialization, timeouts, or machine failures.
In later projects, we can keep the same basic model while changing the transport:
- Worker thread messages
- Messages between processes
- TCP messages between machines
- Task queues
- Kafka events
Once communication crosses a network, we must handle new problems such as timeouts, duplicate messages, partial failures, retries, and unavailable nodes.
What comes next
This example works because we have only one caller and a small number of messages. But imagine sending several get-count requests concurrently. When a response arrives, how do we know which request it belongs to?
Project 02 will solve that problem using correlation IDs to build a proper request/reply protocol.
The complete roadmap and source code are available in the node-distributed-systems-lab repository: https://github.com/pckrishnadas88/node-distributed-systems-lab/tree/main
Top comments (0)