OpusMill

I tested the Node-on-Windows folklore. Half of it is stale.

7 September 2026 · OpusMill

The short version

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

still real Spawning a .cmd without a shell throws EINVAL — and it throws synchronously, which is the part people get wrong.
still real A hardcoded /tmp silently becomes C:\tmp. Confirmed exactly.
worse than described POSIX commands like ps and which are missing — but only from some shells. Same machine, same Node: present from Git Bash, absent from PowerShell.
still real The filesystem is case-insensitive, and require() treats ./Foo and ./foo as one cached module.
did not reproduce The 260-character path limit. A 430-character path created, written and read without complaint.
did not reproduce 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.

Check your own code

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.