is-wsl2
I build on Windows.
I build on Windows. That single fact has quietly shaped more of my tooling than almost anything else, because most of the JavaScript ecosystem assumes you're on a Mac, and the parts that don't assume that assume you're on plain Linux. WSL2 sits in an uncomfortable third place: Linux enough to run the tools, Windows enough to break the assumptions those tools make about filesystems, networking, and process behavior. os.platform() tells you linux when you're inside WSL2, which is technically true and practically useless, because a WSL2 filesystem behaves nothing like a native Linux one when you're crossing the /mnt/c boundary, and code that branches on "am I on Linux" instead of "am I on WSL2 specifically" will confidently do the wrong thing.
is-wsl2 exists to answer the question os.platform() can't: not just "is this Linux," but which Linux, running under what, on top of what.
How it actually detects things
It reads /proc/version, /proc/sys/kernel/osrelease, /etc/os-release, and /proc/1/cgroup, and pieces together an EnvironmentInfo object: WSL or not, WSL1 or WSL2 specifically, which distro, the underlying Windows version if it can get one, whether you're in CI, whether you're in Docker. WSL1 and WSL2 are genuinely different environments under the hood (WSL1 translates syscalls, WSL2 runs an actual Linux kernel in a lightweight VM), and code that cares about filesystem performance or networking behavior needs to know which one it's dealing with, not just "some flavor of Windows-adjacent Linux."
The Windows version lookup is the detail I like most, honestly, because it's a little absurd: from inside WSL2, to get the Windows version string, it shells out to cmd.exe /c ver. You're running Linux, inside a VM, inside Windows, and the only reliable way to ask "what Windows am I actually running on" is to reach back out through the boundary and ask the Windows command interpreter directly. That's not an elegant abstraction. It's a hack that happens to be correct, which is most of what cross-platform detection code actually looks like once you get past the marketing description.
Everything gets cached in a module-level variable after the first call, since none of these facts change during a process's lifetime and reading /proc/version on every single check would be silly.
The bug I'll own
The WSL2 detection line reads like it was written twice and merged carelessly:
osRelease.includes("microsoft") && osRelease.includes("wsl2") || procVersion.includes("microsoft") && osRelease.includes("wsl2")
Operator precedence saves it (&& binds tighter than ||, so it evaluates correctly), but it's redundant. osRelease.includes("wsl2") is checked twice in a condition that only needs to check it once. It reads like I wrote one branch, then went back and added a second signal source without noticing I'd left the redundant clause in from the first pass. It works. It's also exactly the kind of thing that makes a senior engineer wince, and I'd rather say that myself than have someone else point it out first.
There's a second, sharper problem, and this one's not cosmetic: the README's usage example calls isWSL2() (as a function) but the actual export is isWSL2, a plain boolean constant evaluated once at import time. Calling a boolean as a function throws immediately. Anyone who copy-pasted straight from the README would hit a crash on the first line. I wrote the docs assuming a function-based API and shipped a value-based one, and never went back to reconcile them. That's the kind of mismatch that only surfaces when someone other than you actually reads the README instead of the source, which, for a package with essentially one user, is a gap that had no chance to get caught before now.
Why the boolean, not the function
The design choice underneath that bug is actually deliberate, even if the docs never caught up to it. Environment facts don't change mid-process. There's no reason to make someone call isWSL2() every time they want to branch on it, re-running the detection logic, when the answer was fixed the moment the process started. Exporting plain values (isWSL2, isWindows, isWSL1) computed once at import time is the more honest API for something that's a fact about your environment, not a query that could return something different a second later. I just forgot to update the one place that still described it as callable.
What it's actually for
I don't use this in isolation. It's a dependency-of-dependencies kind of tool: something local-llm-router and a couple of my other CLI packages lean on to decide whether to warn about a networking quirk, or whether a filesystem operation needs a different code path because /mnt/c writes are meaningfully slower than native ones. WSL2 users are a real, sizable, and consistently underserved slice of the developer population, and "just use a Mac" isn't advice, it's a way of pretending the problem doesn't exist. I build on Windows because that's the machine I have. is-wsl2 is the small piece of infrastructure that makes the rest of what I build not silently assume everyone else does too.
What's next for it
Fix the README so the usage example matches the actual boolean-export API. That one's overdue and I know exactly where it is. Clean up the redundant WSL2 check, not because it's broken, just because leaving obviously redundant code in a public package is a small, avoidable embarrassment. Beyond that, it does what it says. Zero dependencies, a hundred and forty-five lines, one job. I'd rather have ten of these than one bloated "environment utils" package trying to do everything at once and doing all of it slightly worse.
The specific filesystem problem that started all this
The concrete pain that made me write this in the first place was a Nexli build that ran noticeably slower on one of my machines than another, for no reason I could initially explain: same code, same dependencies, same Node version. The difference turned out to be that one project lived under /mnt/c/Users/... inside WSL2, meaning every file read crossed the Windows/Linux filesystem boundary, and the other lived natively inside the WSL2 filesystem itself. Crossing that boundary is measurably slower (file-watch events, node_modules resolution, anything that touches disk repeatedly during a dev server's hot-reload cycle), and none of it shows up as an error. It just shows up as "this feels slower than it should," which is a much harder thing to debug than a crash, because there's no stack trace pointing at the cause.
Once I understood that, isWSL2 stopped being a curiosity and became something I actually branch on: if a build script detects it's running inside WSL2, it can warn that project files living under /mnt/c will be slower and suggest moving them into the native WSL filesystem instead. That warning doesn't fix the underlying tradeoff (you still have to choose between Windows-side file access and WSL2-native speed), but naming the tradeoff explicitly, instead of leaving someone to discover it through vague unexplained slowness, is most of the value.
Docker detection as a late addition
The Docker check came later than the WSL checks, added almost as an afterthought once I noticed a related problem: code that correctly detected WSL2 but then made assumptions about networking or filesystem behavior that didn't hold once that same WSL2 environment was also running inside a Docker container. Nested virtualization layers each add their own quirks, and isWSL2 alone doesn't tell you whether you're additionally inside a container on top of that. Reading /proc/1/cgroup for Docker-specific markers was a small addition, but it closed a real gap: without it, is-wsl2 was answering "which OS layer am I on" while silently ignoring "how many layers deep am I," and for anything touching networking or file permissions, the second question turns out to matter just as much as the first.
Full stack developer. Founder of Yashveer Labs. Built on Windows, for people building on Windows.
Start a conversation about this.
Whether it's is-wsl2 itself or the next system worth building, the lab is reachable.