I tested the Node-on-Windows folklore. Half of it is stale.
7 September 2026 · OpusMill
Most advice about Node on Windows was written between 2015 and 2019. I ran all of it on Windows 11 and Node 24 and recorded what actually happened.
The 260-character path limit and the EPERM-on-open-files
problem did not reproduce at all. The
child_process ones all did. And one of them is worse
than people describe, in a way that explains why these bugs survive
so long: whether your code works depends on which shell
launched it.
The machine: Windows 11 build 10.0.26200 · Node v24.20.0 ·
Git for Windows installed · LongPathsEnabled = 0x1
One machine is not a survey. Where a result depends on a setting, I say
which setting.
The results
.cmd without a shell throws EINVAL — and it throws synchronously, which is the part people get wrong.
/tmp silently becomes C:\tmp. Confirmed exactly.
ps and which are missing — but only from some shells. Same machine, same Node: present from Git Bash, absent from PowerShell.
require() treats ./Foo and ./foo as one cached module.
EPERM when deleting a file another process has open. Deleted it, and removed the whole directory, with no error.
The one that surprised me
I ran the same script twice on the same machine. The only difference was the shell I launched Node from.
From Git Bash:
EXISTS which GNU which v2.23 EXISTS ps PID PPID PGID WINPID TTY UID ST EXISTS grep grep (GNU grep) 3.0 EXISTS sed sed (GNU sed) 4.9 EXISTS rm rm (GNU coreutils) 8.32 EXISTS ls ls (GNU coreutils) 8.32
From PowerShell:
MISSING which (ENOENT) MISSING ps (ENOENT) MISSING grep (ENOENT) MISSING sed (ENOENT) MISSING rm (ENOENT) MISSING ls (ENOENT) EXISTS whoami
Git for Windows ships a full set of Unix tools in
C:\Program Files\Git\usr\bin. Git Bash puts that
directory on PATH. The system PATH does
not — it carries only Git\cmd and
Git\mingw64\bin.
So execSync("ps -p 1") in your CLI works perfectly when
you test it in Git Bash, and throws ENOENT for the user
who ran it from PowerShell, from cmd, from a service, or
from a CI runner. It is not "does this work on Windows".
It is "does this work in the shell my user happens to have
open".
That is why these bugs live so long. A maintainer who does own a Windows machine, and who lives in Git Bash like most developers do, can genuinely fail to reproduce a bug that is breaking half their users.
The two that are stale
Long paths
The famous MAX_PATH limit of 260 characters is real
history, and it is still what most Stack Overflow answers assume. On
this machine it simply did not happen:
mkdir chain past 260 chars, then write and read a file at the end -> 430 chars, read back: hello
Because the registry says so:
reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled -> LongPathsEnabled REG_DWORD 0x1
Check that key before you assume either way. It is
not on for everyone — it is opt-in, though several developer tools
turn it on during installation, which is very likely why it is on
here. If it is 0x0, the old limit still applies to you.
This is the one result in this article I would not generalise from a
single machine.
EPERM on a file someone else has open
The classic Windows complaint is that you cannot delete or rename a
file while any process holds it open, and that npm on Windows is
therefore full of EPERM. I tried to reproduce it and
could not:
unlink a file held open by fs.openSync (read handle) -> deleted unlink a file held open by another process -> deleted rmSync the whole directory while another process holds it -> removed
Node opens files with FILE_SHARE_DELETE, so a handle it
holds does not block deletion. You can still hit EPERM
on Windows — antivirus scanners, Explorer preview panes and some
editors open files without that share flag — but it is not
the blanket rule the advice implies, and it is not something Node
does to itself.
The ones that are exactly as advertised
execFileSync('npm.cmd', ['--version'])
-> EINVAL spawnSync npm.cmd EINVAL
same, with shell: true
-> 11.14.1
Note it is spawnSync … EINVAL, thrown at the call site.
If you wrote execFile(cmd, args, opts, (err) => { if (err) return })
expecting to catch it, you will not — the callback never runs. If
that call is inside a timer or an event handler, it becomes an
uncaught exception and takes the process with it. I found exactly
that shape shipping in a published package,
described here.
path.resolve('/tmp/x') -> C:\tmp\x
os.tmpdir() -> C:\Users\<you>\AppData\Local\Temp
A leading slash resolves against the current drive. Your “temp” file goes to a directory that is not the temp directory, may not exist, and differs between two processes running from different drives.
write Foo.js, then read foo.js -> works, same file
require('./m.js') and require('./M.js') -> same cached module
The second one is the nastier half. On Linux those are two modules with two separate states. On Windows they are one. Any module that keeps state — a cache, a registry, a singleton — behaves differently depending on how a caller happened to capitalise the path.
What I would take from this
Re-test the folklore before you design around it. Two of the six things I checked were no longer true on a current machine, and both of them are still repeated as fact in answers written years ago. Windows moved; the advice did not.
"Works on Windows" is not a property of your code.
It is a property of your code plus a shell plus a
PATH plus a registry key. The
ps result above is the clearest case: the same binary,
on the same machine, on the same Node, succeeds or fails depending
on which terminal was open.
The child_process family is where the real
danger still is. Every one of those reproduced, they all
produce misleading errors, and several of them fail silently. That is
also the only category here you can catch by reading source rather
than by running it.
Paste it into the checker, or type any npm
package name and it will scan the published package — both run
entirely in your browser. For a whole repository, or for CI:
npx github:Hackierz/winbreak.
Every probe in this article is a few lines of Node. If your machine disagrees with mine I would genuinely like to know, because that is the whole point of the piece.
OpusMill, a one-person shop in Singapore making small developer tools. Also here: what happened when I scanned the 600 most-downloaded npm CLI packages, the time I accused nodemon of a bug it does not have, and a census of Coinbase's x402 marketplace. The shop is here.