The fork() trap in Electron only shows up after packaging

Published:

When a DLL call can block the process, moving that work into a child process feels like the obvious fix. In Electron, that also makes communication straightforward, so using fork() seems like a clean solution.

The basic setup looked like this, with one script for the main side and another for the child process.

main.js

const { fork } = require('child_process');

const child = fork('./child.js');

child.send({ "now": "start" });

child.on('message', (message) => {
  console.log(`Fork process say: ${message.msg} ${message.data}`);
});

child.js

process.on('message', (message) => {
  process.send({ msg: "hello~",  data: "something" });
});

Run directly with Node, and everything works as expected: the scripts can talk to each other, and the blocking DLL call no longer freezes Electron. During development inside Electron, it also appears to work fine. That is exactly why the real problem is easy to miss.

The issue only appears after packaging

The trouble surfaced after building the app with electron-builder. The DLL-backed functionality stopped working. Further testing showed that the script launched through fork() was not actually being executed.

After checking paths, adding logs, and digging around, the root cause turned out to be fork() itself.

At a high level, fork() behaves like a specialized form of spawn():

const child = fork('./child.js');

const child = spawn(process.execPath, ['./child']);

These two lines are effectively doing the same kind of work. If you want spawn() to communicate through child.send() the way fork() does, you need to add IPC explicitly:

const child = spawn(process.execPath, ['./child'], {
  stdio: [0, 1, 2, 'ipc']
});

With that option in place, their behavior and code style become aligned.

The key detail is process.execPath: it points to the executable used to run the current main process.

When the script is started directly with Node, that value is something like:

C:\Program Files (x86)\nodejs\node.exe

So the default behavior of fork() is essentially: use the same executable that started the current process to run the target script. That is where the hidden problem begins.

Why development works but the packaged app fails

Inside an Electron project during development, process.execPath points to:

node_modules\electron\dist\electron.exe

In practice, the files under node_modules\electron\dist behave like a standalone Electron runtime that can execute scripts. So when the app is launched in development with electron ., everything looks normal and no warning signs appear.

But after packaging, process.execPath changes to the built application executable:

dist-dir\productName.exe

That executable is your packaged app, not the development Electron runtime. electron.exe can run scripts, but the packaged application executable does not have the same ability to execute an external script in this way. As a result, after packaging, the code passed to fork() simply never runs.

There is another wrinkle here. Although fork() and spawn() can be compared conceptually, their packaged behavior is not identical.

  • With fork(), the referenced child script is not executed after packaging.
  • With spawn(process.execPath, ...), the app may end up launching itself over and over again.

The spawn() result is easy enough to understand, because you are explicitly asking the current executable to start again. The behavior of fork() is less obvious, and it may not be using process.execPath in exactly the same direct way in every case, but the packaging problem remains the same in practice.

Possible workarounds, none of them very elegant

Once fork() and spawn() stop feeling magical, the situation becomes clearer: they rely on an executable program to interpret and run the script you want to launch.

If you want to keep the overall calling pattern, there are a few ways around the problem, but none of them is particularly nice.

  1. Install Node on every machine that runs the Electron app, and also install whatever dependencies the child process needs.

  2. Copy Electron's dist directory and package it into the app with electron-builder's extraFiles option. This works in principle, but it is awkward because the app ends up carrying a second Electron runtime inside itself.

  3. Use nexe to package child.js into a standalone executable, then launch that executable with spawn(). This can also run into compilation failures.

The fix that actually got used

In the final integration, none of those workarounds ended up being necessary. The DLL was modified so that it no longer blocked the process. Once that happened, it could be called directly from the main process, which removed the need to execute a separate script at all.

That avoided the packaging issue at the root, instead of trying to force fork() to behave in an environment where the packaged executable was never meant to run child scripts the same way as electron.exe during development.