How Node.js Processes Talk to Each Other via IPC and Handles
So far we've seen child processes with stdin, stdout, stderr and all that. But fork() adds one more thing which is kinda made specifically for Node talking to another Node process, i.e. an IPC channel.
IPC here means inter-process communication. Parent gets the same ChildProcess object we've already seen, stdio still works the same way, but now both processes also get a channel where they can send JavaScript values to each other. And in some cases, Node can send network handles along with those messages too.
Let's start with the tiny version first.
const { fork } = require('node:child_process');
const child = fork('./worker.js');
child.on('message', msg => {
console.log('parent', msg);
});
child.send({
type: 'ping',
at: Date.now()
});When parent calls fork(), Node starts another Node process and also creates the IPC connection automatically. Then child.send() sends a serialized value through that connection.
The child listens on its own process object -
process.on('message', msg => {
if (msg.type === 'ping') {
process.send({
type: 'pong',
at: msg.at
});
}
});So parent uses child.send(), child uses process.send(). Parent receives messages from the child's 'message' event, and child receives them from process.on('message').
Pretty small API actually.
Node is doing quite a lot behind those few methods though, so let's go through it properly.
Adding the ipc Stdio Entry
child_process.fork() is meant for starting another Node process, and IPC comes enabled as part of that setup.
You can create the same kind of channel using spawn() too, you just need to ask for it in stdio.
const { spawn } = require('node:child_process');
const child = spawn(process.execPath, ['./worker.js'], {
stdio: ['ignore', 'inherit', 'inherit', 'ipc']
});The first three positions are still stdin, stdout and stderr. Position 3 here says, "also create an IPC channel for this child".
Only one ipc entry is allowed.
Once that IPC connection exists, parent gets subprocess.send(), and the Node child gets process.send(), process.disconnect(), process.connected, plus 'message' and 'disconnect' events.
So from parent -
console.log(Boolean(child.channel));
console.log(child.connected);
child.send({ type: 'hello' });child.channel is the parent's IPC endpoint. child.connected tells you whether Node still considers that channel connected, and child.send() tries sending a message to the child.
Inside child you get something very similar -
console.log(typeof process.send);
console.log(process.connected);
process.send({ type: 'ready' });If the process wasn't started with IPC, process.send won't exist.
console.log(typeof process.send);
// 'undefined'This can actually be pretty useful when the same JS file can run in two ways. Maybe sometimes you execute it directly from terminal, and sometimes parent launches it as a worker.
Then doing this is enough -
if (typeof process.send === 'function') {
process.send({ type: 'ready' });
}Although if the file is only supposed to run as a worker, I'd probably fail early instead.
if (typeof process.send !== 'function') {
throw new Error('worker requires child-process IPC');
}Much nicer than starting up half the worker, opening some files, connecting to something, and then five seconds later crashing because process.send() wasn't there.
One thing to know about the ipc entry is that it still uses one descriptor position inside the child, but Node manages it itself. Don't grab that descriptor and start treating it as some normal file or pipe. The public API is send(), the message events, and the disconnect methods.
There's also one slightly weird process-lifetime detail here.
Node initially leaves the child-side IPC connection unreferenced until the child adds either a 'message' or 'disconnect' listener. That means a child which never actually uses IPC can still finish its other work and exit normally.
For example, if your child has no listeners and no other active work, Node doesn't keep it alive forever just because fork() happened to create IPC.
Once the child starts listening for IPC though, that connection can keep the process running.
Parent can unreference its side too -
child.channel.unref();Now the parent's event loop is allowed to finish even if the IPC connection is still open.
Obviously use this only when you're okay with possibly never seeing some late reply. If the parent's whole job depends on hearing back from that worker, unreferencing the connection probably isn't what you want.
fork() also inherits more from the parent than just "run this JS file".
By default it uses process.execPath, and it also inherits the parent's execArgv. So if parent was started with things such as --inspect, --require, preload flags, or other Node runtime options, child can get those too.
Sometimes that's exactly what you want. Sometimes definitely not. You can remove inherited Node flags while still keeping IPC -
const child = fork('./worker.js', [], {
execArgv: [],
stdio: ['ignore', 'inherit', 'inherit', 'ipc']
});This comes up in test runners, dev tools, process pools, and supervisors quite a bit. Your development parent may be running with debugging flags while the worker should just start normally.
And yes, IPC and ordinary stdio can all exist together.
const child = fork('./worker.js', [], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
});Now child has piped stdin, stdout and stderr, plus IPC.
These are different communication paths. Stdio moves bytes. IPC moves serialized messages, and optionally a supported handle.
That means you can keep stdout and stderr for logs -
worker 7812 started
processed 100 fileswhile IPC carries actual program messages -
process.send({
type: 'progress',
completed: 100
});I prefer that setup because turning stdout into your protocol gets annoying very quickly. One random dependency prints a warning and suddenly your parser is reading log text as protocol data. Great.
If parent and child are both Node, IPC is usually the cleaner place for program state.
You can also use spawn() when you want full control over how Node itself is started -
const child = spawn(process.execPath, ['worker.js', '--mode=one'], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
});This starts Node directly, passes exactly that argv list, captures output, and still adds IPC.
fork() is more convenient when you simply want "run this Node module as a child". spawn() gives you more manual control.
Don't use Node's IPC descriptor as some general communication pipe for arbitrary non-Node programs though. The descriptor carries Node's own child-process IPC protocol. A random executable doesn't know how to participate in that protocol.
For worker processes, I also like sending a readiness message immediately after setup -
process.send({
type: 'ready',
v: 1,
pid: process.pid
});Parent shouldn't assume "the process exists" automatically means "worker is ready to accept work".
Maybe worker still has modules loading. Maybe it's reading config. Maybe it failed connecting to some local resource. Maybe its message handlers aren't installed yet.
A ready message removes that guesswork. And putting a protocol version there is useful too.
Parent may support protocol version 2, while some old worker from a partial deploy is still running version 1. IPC can be perfectly connected while both processes disagree about the message format.
I'd rather find that out at startup than after sending a real job.
Sending Messages Both Ways
The actual message flow is simple. Parent sends -
child.send({
type: 'render',
id: 42,
file: 'a.md'
});Child receives it -
process.on('message', msg => {
if (msg.type === 'render') {
renderFile(msg);
}
});Then child can reply -
process.send({
type: 'done',
id: 42,
bytes: 8192
});And parent receives -
child.on('message', msg => {
if (msg.type === 'done') {
recordDone(msg.id);
}
});Node doesn't know that render means "please render a file". As far as Node is concerned, this is just some serialized data going from one process to another.
Your application gives those fields meaning.
Most IPC protocols end up having a few repeated fields anyway. Something like -
child.send({
v: 1,
id: 'job-9',
type: 'run',
payload
});type says what kind of message it is. id says which request it belongs to. v says which protocol version we're speaking.
Nothing fancy required. But do validate incoming messages.
Even though parent and child may live in the same repository, they are still different running processes. One may be older. One may restart while the other stays alive. Some message may have been queued earlier. Some code path may accidentally send the wrong thing.
A basic check is already better than blindly trusting the object -
function isRun(msg) {
return msg &&
msg.v === 1 &&
msg.type === 'run';
}You can validate more fields if needed. Main point is don't assume every incoming object is automatically one your current code understands.
Request ids become very important as soon as work is async.
Let's say parent sends job 1 and then job 2.
Job 2 happens to hit memory and finishes instantly. Job 1 has to wait for disk and finishes later.
Replies can now arrive -
job 2 done
job 1 doneTotally valid.
So don't depend on "first reply belongs to first request". Put an id on the request and echo the same id back.
process.send({
v: 1,
id: msg.id,
type: 'run:done'
});Now parent can match every reply to the correct request. Also be careful with the callback you pass to send().
child.send({ type: 'run', id }, err => {
if (err) {
markFailed(id, err);
}
});A successful callback does not mean the child finished the work.
It doesn't even mean your child handler accepted the work.
It only tells you Node was able to process the send operation through IPC.
If you need to know whether child accepted the command, child has to tell you -
process.send({
type: 'ack',
id: msg.id
});Then parent handles it -
child.on('message', msg => {
if (msg.type === 'ack') {
markAccepted(msg.id);
}
});So there are really multiple events happening. You asked Node to send something. Node processed that send. Child received it. Child accepted or rejected it. Child eventually finished it.
Those aren't one event, even though a tiny localhost test can make them happen so quickly that it looks that way.
Node also isn't turning this into some durable work queue. If child crashes, IPC doesn't store the job somewhere and replay it later. Messages waiting inside process memory disappear when that process disappears.
This is why send failures, worker failures and application failures should stay separate in your code.
A callback error from child.send() means Node couldn't send through IPC.
A child reply such as -
{
type: 'run:error',
id: 42
}means child received the work and then the work itself failed.
And -
child.on('exit', (code, signal) => {
markChildGone(code, signal);
});means the process itself ended.
You might eventually report all three to some caller as "job failed", sure, but inside the process manager you probably want to know what actually happened.
For request/reply code, a Map works pretty nicely.
const pending = new Map();
function remember(id, resolve, reject) {
pending.set(id, {
resolve,
reject,
startedAt: Date.now()
});
}Now when a reply comes in -
child.on('message', msg => {
const entry = pending.get(msg.id);
if (entry) {
finishReply(entry, msg);
}
});And after a final reply -
function finishReply(entry, msg) {
pending.delete(msg.id);
entry.resolve(msg);
}This gets more useful once you add timeouts too.
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('child timeout'));
}, 10_000);Store that timer with the request and clear it when final reply arrives. Otherwise you've got this fun situation where the job finished successfully, then ten seconds later an old timer wakes up and says it timed out.
Not fun.
Same with child exit. Any requests assigned to that child need some cleanup.
child.once('exit', () => {
for (const id of pending.keys()) {
pending.delete(id);
}
});In real code you'd usually reject those requests rather than silently deleting them, and include the child pid, exit code and signal in the error.
The main idea is just that unfinished request state lives in the parent. Don't expect the IPC connection itself to remember your application state for you.
Serialization Between Processes
Parent and child have different V8 heaps.
That means when you send some JavaScript object to child, you're not handing child the same object from parent memory.
Node serializes it. Then child gets another value created in its own heap. So -
const user = {
name: 'Ish'
};
child.send(user);The user object still lives in the parent's heap. Child gets its own reconstructed value. If child changes -
msg.name = 'Someone else';nothing changes in the parent's original user object.
And if parent sends the same object twice, child receives two serialized message values. Object identity does not somehow cross from one V8 heap into another.
By default child-process IPC uses JSON serialization.
This can surprise you if you send values that aren't normal JSON values.
child.send({
type: 'sample',
when: new Date('2026-01-02T03:04:05Z'),
count: Number.NaN,
missing: undefined
});With JSON mode, that Date arrives as a string. NaN becomes null, and the undefined property disappears.
Functions aren't going across. Symbols aren't going across. Your custom prototype isn't getting recreated.
Private fields, getters and setters don't get reconstructed as some live class instance on the other side either.
You're serializing data.
If your protocol only needs strings, numbers, booleans, arrays and ordinary objects, JSON mode is usually pretty nice because it's easy to inspect.
Logs contain understandable data. Tests can paste the same objects directly.
You can dump a message and read it without wondering what Node-specific type came through.
But Node also supports another mode -
const child = fork('./worker.js', [], {
serialization: 'advanced'
});Advanced serialization uses Node's V8 serialization support and can handle more value types.
For example -
child.send({
type: 'state',
ids: new Set([1, 2]),
total: 10n,
data: Buffer.from('ok')
});This can preserve useful built-in types such as BigInt, Map, Set, ArrayBuffer, typed arrays, Buffer, Error and RegExp.
Still, don't read "advanced" and assume Node is somehow sharing the original objects.
It's serializing them and rebuilding corresponding values in the other process.
For a Node-to-Node internal protocol where both processes are always deployed together, advanced serialization can be convenient.
For a protocol you want easy to log, test and version, JSON is often easier.
Buffers are worth talking about separately.
In JSON mode, a Buffer gets represented using its JSON form. In advanced mode, Node can serialize it and recreate a Buffer on the other side.
child.send({
type: 'chunk',
data: Buffer.from([0x01, 0x02])
});But those bytes are still moving between processes. You didn't suddenly create shared memory.
Sending a 2 KB Buffer once in a while is usually no big issue. Sending 100 MB Buffers repeatedly to ten workers is very different. Sender has serialization work, receiver has deserialization work, IPC has bytes waiting to move, and memory usage can climb while all that is happening.
For large data, sending a path or using some other I/O mechanism can make more sense than putting the whole payload into an IPC message.
Errors are another one.
Advanced serialization can preserve more built-in Error information, but I still prefer explicit protocol errors -
process.send({
type: 'run:error',
code: err.code,
message: err.message
});If parent needs the stack too -
process.send({
type: 'run:error',
code: err.code,
message: err.message,
stack: err.stack
});Now the protocol says exactly what crosses between processes. You're not depending on whatever properties happened to exist on that particular Error object.
We'll get to worker threads later. They have their own cloning and memory rules, and some of the supported values look familiar, but child processes have separate address spaces. Normal IPC values are copied through serialization.
What Happens When You Call send()
Let's say parent does this -
child.send(value);What actually happens after that?
First Node checks whether this ChildProcess still has a connected IPC channel. Then it serializes value using whichever mode you configured.
With default serialization, Node produces the JSON-compatible message data. With advanced mode, it uses V8 serialization.
Then the encoded bytes go into Node's native child-process machinery, which keeps track of pending writes for that IPC connection.
The public parent-side object is available as -
child.channelNode documents that as the IPC pipe. Below the JS API, libuv handles the asynchronous I/O for the connection.
When bytes arrive in the other process, Node parses them and eventually emits -
process.on('message', (msg, handle) => {
// ...
});Most of the time you'll only care about msg.
The second argument is used when sender also passed a supported native handle.
process.on('message', (msg, handle) => {
console.log(msg.type);
console.log(Boolean(handle));
});Message data and handle passing are two separate parts here. Node serializes the first argument.
The optional second value represents some supported OS resource, such as a TCP server or socket.
We'll get into that in a bit.
There is also a queue between your JS send() call and actual IPC writing.
So if your code can produce messages faster than Node can write them to child, those pending messages have to sit somewhere.
They sit in the sender process. Which means memory usage can start increasing.
Large messages make this worse because serialization happens before Node can finish moving the data away.
This is also why the send callback and the receiver's response should never be treated as the same event.
Parent may get a successful callback while child still hasn't run the relevant 'message' handler.
Child may be busy running JavaScript. Child could even exit after parent got the successful callback.
If parent needs actual confirmation from child, child has to send an acknowledgement or final response.
There are roughly three useful checkpoints -
send() return value
send() callback
reply from childThe boolean return value gives you an immediate hint about IPC pressure or a closed channel.
The callback tells you how Node handled the send request. The reply tells you something about your own protocol. Different things.
One more detail - messages on one connected parent-child channel are emitted in send order.
But don't confuse message order with work completion order. Child can receive -
job 1
job 2then start asynchronous work for both, and finish job 2 before job 1.
If your worker is supposed to handle only one command at once, you need to implement that policy yourself.
let busy = false;
process.on('message', msg => {
if (busy) {
return process.send({
type: 'busy',
id: msg.id
});
}
busy = true;
// start work
});Some workers allow several jobs at once. Some maintain their own queue. Some reject new work while busy. IPC doesn't decide that for you.
Send Queue Pressure
send() returns a boolean, and you shouldn't just ignore it in code that sends a lot of messages.
const ok = child.send(
{ type: 'batch', rows },
err => {
if (err) {
failBatch(err);
}
}
);
if (ok === false) {
pauseProducer();
}A false return can mean the channel has already closed, or Node's queue of unsent messages crossed its internal threshold.
You can check connection state -
if (child.connected === false) {
discardPendingFor(child.pid);
}If child is still connected and send() returned false, you're probably producing faster than IPC is flushing.
And unlike a writable stream, ChildProcess.send() doesn't give you a nice 'drain' event.
So you need your own flow control. You can track send callbacks -
let inFlight = 0;
function sendJob(job) {
inFlight++;
return child.send(job, () => {
inFlight--;
});
}But careful with the name inFlight there. That counter only tracks sends waiting for callbacks.
It does not tell you how many jobs the child is still processing.
Those are different counters. You might have -
0 send callbacks pending
30 jobs still running in childbecause all 30 messages were already written successfully.
For a worker pool, you usually want to limit actual unfinished jobs too.
const maxInFlight = 32;
function canSendMore() {
return child.connected && inFlight < maxInFlight;
}What exactly inFlight counts is your decision. For CPU jobs, counting unfinished jobs is probably more useful. For telemetry, maybe send callbacks are enough. For socket handoff, you'd probably track active handed-off connections separately. Another approach is letting child ask for work -
process.send({
type: 'pull',
slots: 4
});Parent now knows child has four free slots and sends at most four jobs.
I generally find this easier to reason about than parent continuously pushing work until Node starts returning false.
Push-based designs work too, they just need an actual limit.
if (sendJob(job) === false) {
producer.pause();
}Then you need some real condition for resuming. Maybe enough send callbacks completed. Maybe child sent acknowledgements. Maybe child sent another pull message.
Using a timer and hoping the queue has recovered by then is less reliable because you're just guessing.
Also, large messages deserve some suspicion. This -
child.send({
type: 'work',
payload: fiftyMegabyteBuffer
});may technically work, but now you're asking IPC to move 50 MB through a serialized process-to-process message.
Do that repeatedly and memory can go up very fast. For large files, you can often send -
{
type: 'process-file',
path: '/tmp/upload-91.bin'
}and let child open the file itself.
IPC is usually nicer for control messages and reasonably sized data.
Same pressure problem exists in child-to-parent direction too. This is easy to write -
for (const row of rows) {
process.send({
type: 'progress',
row
});
}And then suddenly worker is generating thousands or millions of IPC messages.
Maybe parent doesn't need that. You can batch progress -
if (done % 1000 === 0) {
process.send({
type: 'progress',
done
});
}Parent still knows work is moving, but child isn't sending one message for every tiny unit.
If you're debugging pressure issues, there isn't one perfect JavaScript number that says "IPC queue currently contains exactly 72 MB".
But you can watch several things together, i.e. process RSS, heap use, count of unfinished work, send return values and how quickly callbacks are completing.
If RSS keeps climbing, send() keeps returning false, and unfinished work keeps increasing, parent needs to slow down.
Shutdown has the same issue.
Suppose process receives SIGTERM and you respond by sending 20,000 cancellation messages to children.
Now you've created a huge IPC queue while also trying to shut down within a deadline.
Usually one shutdown command is cleaner -
child.send({
type: 'shutdown'
});Stop giving that child new work, let existing jobs finish or report cancellation, then close IPC when your protocol says it's okay.
Reserved NODE_ Commands
Node reserves one message naming convention for itself. Look at these -
child.send({
cmd: 'APP_START',
id: 7
});
child.send({
cmd: 'NODE_START',
id: 7
});A message whose cmd property starts with NODE_ is treated specially by Node core.
It doesn't go through the child's normal 'message' event in the usual way.
So don't name your application messages that way. I'd just use type -
{
type: 'job:start',
id: 7
}or -
{
type: 'APP_START',
id: 7
}Much less chance of colliding with Node internals.
There is also an 'internalMessage' event used by Node's own machinery. I wouldn't build application code around that. Internal protocol details can change between Node versions.
For app IPC, ordinary 'message' events and a type field are enough.
Disconnect and Process Lifetime
When you're done with IPC, parent can disconnect it -
child.on('disconnect', () => {
console.log('ipc closed');
});
child.disconnect();
console.log(child.connected);After that connection closes, child.connected becomes false. Child sees the same state through -
process.connectedand child gets its own 'disconnect' event. Child can also disconnect first -
process.send({
type: 'done'
});
process.disconnect();This can be nice for one-shot workers.
Child sends its final result, closes the IPC connection, and then if nothing else is keeping the event loop alive, process can finish naturally.
But closing IPC does not automatically mean child exits. Maybe child still has a timer. Maybe there's an open server. Maybe some file operation is still running. Maybe there is a socket keeping the event loop alive.
IPC and process lifetime are related, but disconnecting IPC isn't the same as calling process.exit().
A child can listen for parent disconnect -
process.on('disconnect', () => {
flushMetrics().finally(() => {
process.exit(0);
});
});This is one way to handle planned parent shutdown. Still, don't assume parent will always disappear nicely. Maybe it gets killed. Maybe it crashes. Maybe host goes down.
Depending on timing and OS behavior, child may see disconnect, a send error, or just stop receiving expected heartbeat messages.
Parent should also listen for lifecycle events separately -
child.on('message', onMessage);
child.on('disconnect', onDisconnect);
child.on('exit', onExit);
child.on('error', onError);These events do not all mean the same thing. 'disconnect' says IPC has closed. 'exit' says the child process ended. 'error' says some child-process operation failed. A child can disconnect and stay alive. A child can exit, which also causes IPC to disappear.
So don't log every one of these as "worker died". Sometimes worker didn't die at all.
Also, if you call disconnect() while you still have protocol work pending, your application has to decide what happens to that work.
Maybe child already acknowledged it. Maybe it hasn't. Maybe you retry elsewhere. Maybe the operation isn't safe to retry.
A previous send() returning true is not enough to answer those questions.
Connection-state guards are still useful -
if (process.connected) {
process.send({
type: 'progress',
done
});
}But this doesn't remove races.
Connection can close right after the if check and before process.send() actually goes through.
So keep the send callback or error handling too. Parent can guard its disconnect call as well -
if (child.connected) {
child.disconnect();
}This avoids calling disconnect() when there is no active IPC channel anymore.
Long-running supervisors should also clean up listeners after a child is finished -
child.once('exit', () => {
child.removeAllListeners('message');
});Otherwise old child objects can end up retaining request maps, closures, buffers or other data much longer than needed.
Not exciting code, but process managers live for a long time, so small leftovers can accumulate.
Passing Server Handles
Now we get to the more interesting IPC feature.
Messages aren't the only thing Node can send between parent and child.
For some supported network objects, send() takes a second argument called a sendHandle.
Something like -
child.send(message, handle);That handle can be a supported net.Server, net.Socket, or dgram.Socket, depending on platform and the exact case.
Let's start with a TCP server. Parent creates the server -
const { fork } = require('node:child_process');
const net = require('node:net');
const child = fork('./worker.js');
const server = net.createServer();
server.listen(3000, () => {
child.send(
{ type: 'server' },
server
);
});First argument -
{ type: 'server' }is normal message data. Second argument -
serveris the server handle. Child gets both -
process.on('message', (msg, server) => {
if (msg.type === 'server') {
server.on('connection', socket => {
socket.end('child\n');
});
}
});Child now has a net.Server wrapper for that received server handle and can attach connection listeners to it.
The server was already listening. Child doesn't have to call listen(3000) again.
This means parent and child can both end up operating on a listening server, depending on how you keep the local wrappers open.
If parent keeps its server active and child also accepts from the passed server, connections can be handled across those processes.
If your plan is for child to take over, it's better to make that explicit instead of leaving both sides active without knowing which one should accept.
A readiness acknowledgement is useful here too. Child -
process.on('message', (msg, server) => {
if (msg.type === 'server') {
server.on('connection', onConnection);
process.send({
type: 'server:ready'
});
}
});Parent can wait for that -
child.once('message', msg => {
if (msg.type === 'server:ready') {
server.close();
}
});Now parent only closes its own server wrapper after child has actually attached its handler.
The send() callback isn't enough for that. It can tell parent Node processed the handle send, but it can't tell you whether child has already installed a 'connection' listener.
That's application-level readiness again. Cleanup needs some thought too.
If parent passes a server handle and later closes its local server, child can still have its own received server object.
If child dies, parent needs to decide whether it keeps accepting connections itself, hands the server to some other worker, or shuts the service down.
There isn't one JavaScript object shared by both processes. Each process has its own wrapper around related OS state.
UDP can also participate through dgram.Socket in supported configurations.
Node documents UDP handle sharing with platform restrictions, so if your code depends on Unix-only behavior, make that visible in code -
if (process.platform !== 'win32') {
child.send(
{ type: 'udp' },
udpSocket
);
}Don't hide a platform dependency somewhere in deployment docs and then act surprised when Windows blows up later.
If your service is Linux-only, fine. Just make the expectation obvious.
Passing Connected Sockets
You can also pass an individual connected TCP socket. Parent accepts a connection -
const server = net.createServer({
pauseOnConnect: true
});
server.on('connection', socket => {
child.send(
{ type: 'socket' },
socket
);
});pauseOnConnect: true is useful here because Node pauses reads on the newly accepted connection before your app starts consuming data.
Then child receives the socket -
process.on('message', (msg, socket) => {
if (msg.type === 'socket') {
socket.resume();
socket.end('handled\n');
}
});Parent accepted the connection, but child is now doing the actual work on that socket.
Why pause it first?
Because otherwise parent may already start reading bytes before the socket gets handed over.
And once parent JavaScript has consumed bytes, those bytes don't magically come back when child gets the socket handle.
So for clean handoff, pausing before routing is usually easier. send() also accepts a keepOpen option for connected sockets -
child.send(
{ type: 'socket' },
socket,
{ keepOpen: true }
);With keepOpen: true, sender keeps its side open too.
Default is false, so sender gives up its local side as part of the transfer.
For ordinary "parent routes connection to worker" designs, default behavior is usually easier. One process owns the socket after transfer.
Keeping it open in both processes means both can now be involved in reads, writes, shutdown and errors for the same connection. You'd want a very specific protocol before doing that.
Socket handoff also needs error handling. Parent -
child.send(
{ type: 'socket' },
socket,
err => {
if (err) {
socket.destroy();
}
}
);If sending the handle fails, destroy the client socket instead of leaving some connection sitting there with nobody reading it.
Child should add its own handlers once it gets ownership -
socket.on('error', logSocketError);
socket.on('close', markSocketClosed);After transfer, child handles the active socket lifecycle. Parent can still track something such as -
connection 98 sent to pid 8120but child now owns the reads and writes.
If parent distributes sockets across several children, remove dead children from your routing set -
child.on('exit', () => {
routingSet.delete(child.pid);
});That's the minimum.
A better router may also track whether each child sent ready, how many sockets it currently has, whether it's draining, and whether recent handoffs failed.
Just because child.connected === true doesn't mean that child has spare CPU or memory for another thousand connections.
One more thing.
If parent wants to inspect some bytes before choosing a worker, be explicit about it.
Maybe parent reads the first few bytes to identify a protocol.
Those bytes have now been consumed by parent JavaScript. Passing the socket handle won't automatically replay them to child.
If child needs them too, send the bytes in the message -
child.send(
{
type: 'socket',
prefix
},
socket,
err => {
if (err) {
socket.destroy();
}
}
);Now child receives two things, i.e. the live socket handle and the prefix parent already read.
That's more code, yes, but at least both processes agree on which bytes were consumed.
Also check platform support before building your whole socket-routing system around handle passing. Some IPC socket cases are not available on Windows.
Protocol Rules You Should Decide Early
Once IPC becomes more than two demo messages, you need a small protocol.
Nothing huge. Just decide a few things early so parent and child don't slowly become impossible to reason about.
First one - startup. Child should announce when it is ready -
process.send({
type: 'ready',
v: 1,
pid: process.pid
});Parent waits -
child.once('message', msg => {
if (msg.type === 'ready') {
child.send({
type: 'run',
v: 1
});
}
});Without a handshake, parent may start sending work while child is still loading modules or doing startup work.
Add a startup timeout too -
const timer = setTimeout(() => {
child.kill();
}, 5000);
child.once('message', msg => {
if (msg.type === 'ready') {
clearTimeout(timer);
}
});That's a startup timeout. Job timeouts should be separate and tracked by request id.
Don't create one global five-second timer and use it for every possible operation. A healthy worker can easily have one job that legitimately takes longer.
Version your messages too -
child.send({
v: 1,
id: '7',
type: 'render',
input
});Child can reject versions it doesn't understand -
process.on('message', msg => {
if (msg.v !== 1) {
return process.send({
type: 'reject',
id: msg.id
});
}
runMessage(msg);
});This helps during rolling deployments where parent and child aren't guaranteed to start from the exact same build at the exact same second.
And treat process death as normal process-management stuff, because processes do die.
child.on('exit', () => {
for (const id of inFlight.keys()) {
markUnknown(id);
}
});I like markUnknown() here more than immediately saying every job failed.
Why? Parent may have sent the job. Child may have completed the side effect. Then child died before parent received the reply. Parent cannot always know which one happened. For idempotent work, maybe you retry.
For something with side effects, you probably need some external record which can tell you whether operation already happened.
IPC by itself cannot answer that after both process state and message queue are gone.
Also define limits. How large can one message be? How many jobs can be unfinished? How long does startup get? How long can one job run? What happens when child disconnects? What happens during shutdown?
You don't need a 40-page protocol document. But if none of these are decided, the defaults usually become "keep sending until memory gets weird".
For JSON messages, even a crude message-size check can help -
if (
Buffer.byteLength(JSON.stringify(msg)) > maxBytes
) {
throw new Error('ipc message too large');
}Not perfect, but now you at least have a stated limit.
Logs should also carry the same identifiers your protocol uses.
log.info(
{
pid: child.pid,
id,
type
},
'ipc send'
);Then use the same id for reply, timeout, disconnect and exit-related cleanup.
Otherwise parent log says -
child exitedand child log says -
job completedand you're staring at both wondering whether they're talking about the same job.
A useful trace can be as simple as -
parent send id=7 type=run
parent send-ok id=7
child recv id=7 type=run
child reply id=7 type=run:done
parent recv id=7 type=run:doneIf one line is missing, you've already narrowed the problem down.
No send-ok? Look at the send path.
No child recv? Look at child startup, scheduling or IPC connection state.
Child received the job but parent never saw reply? Now look at child work, child send, parent connection state and parent handlers.
Log protocol version too -
log.debug(
{
id: msg.id,
v: msg.v,
type: msg.type
},
'ipc recv'
);When an old child is still running after a deploy, seeing v: 1 next to a parent expecting v: 2 saves quite a lot of guessing.
Shutting IPC Down Cleanly
A basic shutdown can look like this -
child.send(
{ type: 'shutdown' },
err => {
if (err) {
return child.kill();
}
child.disconnect();
}
);This says - ask Node to send the shutdown command, and if send itself fails, terminate the child.
But remember what we discussed earlier. A successful send callback does not mean child finished shutdown work.
If child has jobs to finish or resources to close, better protocol may be -
Parent -
child.send({
type: 'shutdown'
});Child -
process.on('message', async msg => {
if (msg.type !== 'shutdown') {
return;
}
await stopAcceptingWork();
await finishActiveJobs();
process.send({
type: 'shutdown:ready'
});
});Then parent disconnects only after receiving that reply.
This gives child time to stop taking new work and finish whatever your app considers safe to finish.
After IPC disconnects, parent should still watch 'exit'. Those are two separate events.
Child saying "I'm ready for IPC to close" is not the same as child process actually being gone.
For a process pool or supervisor, your states might be something roughly along these lines -
starting
ready
busy
draining
disconnected
exitedYou don't need these exact names, but having explicit process state is much nicer than trying to infer everything from one boolean.
Where IPC Fits
Parent-child IPC is really useful when you've got Node processes on the same machine and they already have a parent-child relationship.
Process pools use it. Local supervisors use it. Workers can report progress through it. Parent can send commands. Child can report readiness and shutdown status.
You can even hand off TCP servers and connected sockets in supported cases.
But the channel lives with those processes. Queues are in memory. If parent dies, its queued messages die with it. If child dies, its queued messages die with it. There is no built-in replay. There is no persistence.
There is no "deliver this tomorrow when another machine comes online".
And that's completely fine because IPC isn't trying to solve those problems.
For local process coordination, fork() plus send() is often all you need.
Just remember there are several things happening separately - message serialization, the IPC send queue, application acknowledgements, child lifecycle, request tracking, and sometimes OS handle transfer too.
Once you keep those separate in your head, the API gets much easier to work with.
Parent sends serialized data. Child gets its own copy. Request ids connect replies back to requests.
send() returning false tells you to slow down or inspect connection state.
disconnect() closes IPC, not necessarily the process.
And if you pass a server or socket, you're no longer sending only JavaScript data. You're also asking Node to hand another process access to an OS network resource.
That's most of child-process IPC.
Well, most of the stuff you'll probably need before building your own tiny process manager and then discovering you have somehow spent the weekend implementing half a supervisor.