← Back to list

I Built a Process Manager to Stop Drowning in Node.js Internals

I was trying to understand how Node.js handles HTTP connections under the hood.

Jyaman · 2026-05-30 13:40 · 0 claps · 7.3 min read
#how-pm2-works #pm2 #nodejs #design-process #cluster
Open on Medium ↗
Wiki topics: 🌐 · Web Development

I Built a Process Manager to Stop Drowning in Node.js Internals

I was trying to understand how Node.js handles HTTP connections under the hood.

Simple enough. I opened the docs, started reading about HTTP agents — how they manage socket pooling, keep-alive connections, how many requests a single socket can handle before it gets recycled. That led me to sockets. Sockets led me to streams. Streams led me to buffers. Buffers led me to how the OS allocates memory. And suddenly I’m reading about kernel memory management at 1am when all I wanted to do was understand keep-alive.

I closed the docs. Opened Claude. Started talking through what I actually wanted to build — a tool to manage and monitor Node.js processes, something like PM2 but built from scratch so I’d actually understand how it works. Talking it through helped me realize I didn’t need to understand everything. I needed to build something real and let the gaps find me naturally.

That tool became MonitorX.

This is what I learned building it.

What a Process Actually Is

Before writing any code, I needed to understand what I was managing.

A process is just a running program — a worker the OS has given memory and CPU time. Each process gets a unique PID (Process ID).

But processes don’t live in isolation. The OS organizes them into process groups. Every process belongs to a group, identified by a PGID. The first process in a group becomes the group leader — its PID and PGID are the same.

PID   PGID   What it is
200   200    ← group leader (e.g. your shell)
201   200    ← child process
202   200    ← another child

Groups are organized into sessions. A session is typically tied to a terminal — all the processes you launch from one terminal window share a session. When you close that terminal, the OS sends a SIGHUP to the session leader, which cascades down.

This hierarchy matters for a process manager because killing a process and killing a process group are different operations:

  • process.kill(pid) — kills one process
  • process.kill(-pgid) — kills the entire group

If you only kill the parent and it has spawned children, those children become orphans — still running, attached to nothing. This is a real class of bugs in naive process managers.

The Architecture: Daemon + CLI

MonitorX has two parts: a daemon that runs in the background and manages your processes, and a CLI that sends commands to it.

monitorx start server.js
       │
       ▼
    [CLI] ──── Unix Socket ────▶ [Daemon]
                                     │
                              ┌──────┴───────┐
                              │              │
                         [server.js]    [worker.js x4]

The CLI is stateless. It connects to the daemon over a Unix socket, sends a JSON message, reads the response, and exits. The daemon holds all the state — which processes are running, their PIDs, their logs.

How the Daemon Starts

When you run monitorx-daemon --daemonize, this happens:

if (process.argv.includes("--daemonize")) {
  const child = spawn(process.execPath, [process.argv[1], ...args], {
    detached: true,
    stdio: "ignore",
  });
  child.unref();
  process.exit(0);
}

Three things make this work:

**detached: true** tells the OS to make the child process a session leader — it's no longer tied to the parent's process group. Close the terminal, the daemon keeps running.

**stdio: "ignore"** closes all I/O handles between parent and child. If you don't do this, the parent process keeps a reference to the child's stdio streams, which prevents it from fully exiting.

**child.unref()* is the subtle one. Node.js keeps its event loop alive as long as there are active handles. By default, a spawned child is an active handle — the parent will wait for it before exiting. unref() tells Node to not* count this child in the event loop. The parent can exit freely even though the child is still running.

Without unref(), your "background daemon" would hold the terminal hostage.

The Watcher/Worker Split

The daemon doesn’t run directly. It uses a two-process pattern:

function startWatcher() {
  function spawnWorker() {
    worker = fork(process.argv[1], [...args, "--is-worker"], {
      stdio: "inherit",
    });
    worker.on("exit", (code) => {
      if (!shuttingDown && code !== 0) {
        console.error(`Worker crashed. Restarting...`);
        setTimeout(spawnWorker, 1000);
      }
    });
  }
  spawnWorker();
}

The watcher (outer process) does nothing except watch the worker (inner process). If the worker crashes for any reason, the watcher restarts it after a 1-second delay. Your managed processes survive because the worker restores them from a state file on startup.

This pattern separates concerns cleanly: the watcher doesn’t need to know anything about processes or sockets. It just keeps the worker alive.

IPC: Talking Over a Unix Socket

The CLI and daemon communicate via a Unix domain socket at ~/.monitorx/daemon.sock. Unlike TCP sockets, Unix sockets are just files — no network stack, lower overhead, and they naturally restrict access to the local machine.

The protocol is newline-delimited JSON. Each message is a single JSON object followed by \n.

On the daemon side, reading it looks like this:

let buf = "";
socket.on("data", (chunk) => {
  buf += chunk.toString();
  const lines = buf.split("\n");
  buf = lines.pop()!; // keep incomplete line for next chunk
  for (const line of lines) {
    if (line.trim()) {
      const message = JSON.parse(line);
      handle(message, socket);
    }
  }
});

This buf pattern is something you hit whenever you work with streams: TCP doesn't guarantee that a single data event contains exactly one message. The sender writes {"type":"ls"}\n but the receiver might get {"type in one chunk and ":"ls"}\n in the next. You have to buffer and split on the delimiter yourself.

This is where my stream/buffer rabbit hole from the docs actually landed — not in some theoretical OS concept, but in six lines of code I had to write to make the CLI work.

The Four Start Cases

One of the harder design problems was the monitorx start command. It needs to handle four different inputs:

monitorx start              # reads monitorx.config.js, starts all processes
monitorx start 3            # starts stopped process with ID 3
monitorx start server.js    # starts this file directly
monitorx start api          # restarts stopped process named "api"

The first implementation sent each process to the daemon one by one in a loop. That caused a bug: each send triggered a response, and each response called socket.unref(), causing the connection to drop before all processes were started.

The fix was to batch everything into a single message with an array of processes. One send, one response, no race condition.

The input parsing logic itself is a case statement — is it a number? An existing file? Neither (so it’s a name)?

// 1. Is it a number? → start by ID
// 2. Does the file exist on disk? → start as script
// 3. Otherwise → treat as a process name, find and restart it

Simple logic, but getting the edge cases right took more iterations than I expected. What happens if you pass a name that doesn’t exist? What if a cluster has 4 workers and you start by name — should all 4 restart or just the stopped ones?

Cluster Mode

MonitorX supports running multiple workers for a single process — sharing one port, load-balanced by the OS.

monitorx start server.js -i 4

This uses Node.js’s built-in cluster module. The daemon acts as the cluster primary. Workers are spawned with cluster.fork(), and the OS distributes incoming connections across them round-robin at the kernel level.

The key insight: your server code doesn’t change at all.

// server.js — no cluster code needed
import http from 'node:http';
http.createServer((req, res) => {
  res.end(`hello from PID ${process.pid}\n`);
}).listen(3000);

Run it with -i 4 and you get four workers all listening on port 3000. Each request will hit a different PID.

Crash Recovery

The tricky part is what happens when a cluster worker crashes. Node’s cluster module emits an exit event on the primary. MonitorX hooks into it:

cluster.on("exit", (worker, code, signal) => {
  for (const [id, state] of this.processes) {
    if (state.mode === "cluster" && state.workerId === worker.id) {
      if (state.status !== "stopped" && signal !== "SIGTERM" && code !== 0) {
        // Unexpected crash — restart the worker
        const newWorker = this._doFork(state.script, state.cwd);
        state.child = newWorker.process;
        state.workerId = newWorker.id;
        state.startTime = Date.now();
      }
      break;
    }
  }
});

The state.status !== "stopped" check is critical. When you run monitorx stop api, the stop handler sets state.status = "stopped" before calling .kill(). So when the cluster exit event fires, the handler sees status === "stopped" and knows not to restart. Without this, every intentional stop would immediately respawn the worker.

State Persistence

The daemon writes all process state to ~/.monitorx/process_state.json after every change:

[
  { "id": 1, "name": "api", "script": "node src/server.js", "status": "running", "mode": "fork" },
  { "id": 2, "name": "worker", "script": "node src/worker.js", "status": "running", "mode": "cluster", "instances": 4 }
]

When the daemon starts (or the worker restarts after a crash), it reads this file and relaunches everything that was running. Your processes survive daemon restarts transparently.

The alternative — keeping state only in memory — means a daemon crash wipes your entire process list. Annoying if you manage 10 services.

Log Streaming

MonitorX keeps a 64KB ring buffer per process in memory:

const MAX_BUFFER = 64 * 1024; // 64 KB
const handleOutput = (data: Buffer): void => {
  state.buffer.append(data);
  if (state.buffer.length > MAX_BUFFER) {
    state.buffer.consume(state.buffer.length - MAX_BUFFER);
  }
  // Fan out to all connected log subscribers
  for (const sub of state.subscribers) {
    sub.write(JSON.stringify({ type: "log", id, data: data.toString() }));
  }
};
state.child.stdout?.on("data", handleOutput);
state.child.stderr?.on("data", handleOutput);

When you run monitorx logs api, the CLI subscribes to the socket. The daemon immediately replays the ring buffer (so you see recent history), then streams new output as it arrives. When you Ctrl+C, the socket closes and the daemon removes that subscriber.

This pub/sub pattern — process stdout → daemon buffer → fan out to N subscribers — is the same model used by log aggregators at scale. The daemon here is your log broker.

What I’d Do Differently

CPU and memory tracking is wrong. Right now, _getProcessResourceUsage() reads process.resourceUsage() — which is the daemon's own resource usage, not the child process's. Getting accurate per-child stats requires either /proc/<pid>/stat on Linux or pidusage npm package. It's on the list.

The IPC protocol has no versioning. If you install a new daemon version but still have an old CLI binary cached somewhere, the messages are silently incompatible. Adding a version field to every message and rejecting mismatches would make this more robust.

No graceful shutdown for cluster workers. Currently stop sends SIGTERM immediately. A production-grade manager would send a signal, wait for in-flight requests to drain (using a close event on the HTTP server), then force-kill after a timeout.

What Building This Actually Taught Me

The streams/buffers/OS rabbit hole wasn’t wasted. But reading about it in isolation gave me no anchor — it was abstract concepts with no practical frame.

Building MonitorX gave me the frame. Now when I read about buffers, I think about the 64KB ring per process. When I read about Unix sockets, I think about daemon.sock. When I read about process groups, I think about the status = "stopped" guard before .kill().

The lesson isn’t “stop reading docs and just build.” It’s closer to: build something, hit a real problem, then go read the docs for exactly that problem. The rabbit hole doesn’t feel like drowning when there’s a reason you’re in it.

MonitorX is on npm as [@yamanzyan/monitorx](https://www.npmjs.com/package/@yamanzyan/monitorx). Install it globally and try it on your own projects. The source is the learn folder — messy in places, but honest.


메타데이터
post_id
ca28ae5c3e7e
slug
i-built-a-process-manager-to-stop-drowning-in-node-js-internals-ca28ae5c3e7e
url
https://medium.com/@jyaman694/i-built-a-process-manager-to-stop-drowning-in-node-js-internals-ca28ae5c3e7e
canonical_url
https://medium.com/@jyaman694/i-built-a-process-manager-to-stop-drowning-in-node-js-internals-ca28ae5c3e7e
author_url
https://medium.com/@jyaman694
status
ok
fetched_at
2026-06-09 15:37:30