DEV Community

Cover image for Building Message Passing in Node.js from Scratch
krishnadaspc
krishnadaspc

Posted on

Building Message Passing in Node.js from Scratch

"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:

  1. Increment the counter by 1.
  2. Increment it by 4.
  3. Return the current count.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Let us look at the important parts.

const worker = new Worker(workerPath);
Enter fullscreen mode Exit fullscreen mode

This starts worker.js in a separate worker thread.

worker.postMessage({
  type: "increment",
  amount: 1,
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

The counter is declared inside the worker:

let count = 0;
Enter fullscreen mode Exit fullscreen mode

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.
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

but worker.js reads message.value, the value is undefined:

count += message.value;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)