Fix esbuild spawnSync ETXTBSY in Docker: Node 20.3 io_uring
Why npm install of esbuild started failing with ETXTBSY on node:20.3 images in June 2023, what changed in libuv and Docker since, and the build fix that holds today.
TL;DR#
npm install fails inside esbuild's install script with spawnSync …/node_modules/esbuild/bin/esbuild ETXTBSY ("Text file busy") on node:20.3.0 images. Node.js 20.3.0 upgraded to libuv 1.45.0, the first libuv release with io_uring support for file operations on Linux, and in Docker on some kernels that produced ETXTBSY when a just-written binary was executed. The durable fix is a newer base image: on the 20.x line io_uring is opt-in since Node.js 20.11.1, and the libuv in Node 22 no longer creates the file-operation ring by default. If you are stuck on a 20.3 to 20.11.0 image, set UV_USE_IO_URING=0 in the Dockerfile before npm install.
The Stack Overflow question shows the failure in a BuildKit RUN npm install step, on Node.js v20.3.0 with npm 9.6.7. The same output, without BuildKit's line prefixes, came from a plain npm i vite@latest (which installs esbuild as a dependency) in a node:20.3 container in esbuild issue #3156:
npm ERR! code 1
npm ERR! path /app/node_modules/esbuild
npm ERR! command failed
npm ERR! command sh -c node install.js
npm ERR! node:internal/errors:496
npm ERR! ErrorCaptureStackTrace(err);
npm ERR! ^
npm ERR!
npm ERR! <ref *1> Error: spawnSync /app/node_modules/esbuild/bin/esbuild ETXTBSY
npm ERR! at Object.spawnSync (node:internal/child_process:1117:20)
npm ERR! at spawnSync (node:child_process:871:24)
npm ERR! at Object.execFileSync (node:child_process:914:15)
npm ERR! at validateBinaryVersion (/app/node_modules/esbuild/install.js:98:28)
npm ERR! at /app/node_modules/esbuild/install.js:285:5 {
npm ERR! errno: -26,
npm ERR! code: 'ETXTBSY',The question's author had already cleared the Docker cache and removed linked node_modules, and could reproduce it locally and in CI. That is a strong hint the problem was not in the project at all.
Why it happens#
Read the stack from the bottom. npm runs esbuild's postinstall script (node install.js). In esbuild's installer source, validateBinaryVersion calls child_process.execFileSync on the platform binary it has just put in place, with --version, to check that the binary matches the package. ETXTBSY (errno 26, "Text file busy") is the error the execve(2) manual gives when "the specified executable was open for writing by one or more processes". esbuild did not change; the execute call simply started failing.
What changed was the image. The esbuild issue #3156 collected the same failure from several people: node:20.2 worked, node:20.3 failed, on Docker Desktop for Apple silicon and on GKE CI builders alike. The maintainer explained that the installer moves the binary into place with fs.renameSync rather than writing it in place, closed the issue as not an esbuild problem, and pointed to nodejs/docker-node#1912. There, people hit the same error from tools that have nothing to do with esbuild: yarn build running tsc, husky install, semantic-release.
The thread narrowed it down to the libuv upgrade in Node.js 20.3.0. libuv 1.45.0 introduced io_uring support on Linux, and a reporter found that setting UV_USE_IO_URING=0 (a libuv debugging switch at the time) made the esbuild installs pass on every Node 20.3.0 image they tried. The libuv maintainers then traced the kernel side. The fix, libuv PR #4059, describes it as "a poorly understood bug in older kernels where closing a file descriptor pointing to /foo/bar results in ETXTBSY errors when trying to execve("/foo/bar") later on". The same PR says the bug "seems to have been fixed somewhere between 5.15.78 and 5.15.98" and that it "seems to manifest only when running under Docker". That matches the reports: the Docker Desktop VMs in the docker-node thread ran a 5.15.49 linuxkit kernel.
The libuv maintainers do not document which close, in which process, held the file open, and neither does this page. What the sources establish is the chain: Node 20.3.0, libuv io_uring, an affected kernel, and then execve on a freshly written file returns ETXTBSY.
What was reproduced here, and what was not#
The error itself did not reproduce on this machine. On 19 October 2026, with Docker Engine 29.7.2 on a 6.18.33.2 WSL2 kernel, the official node:20.3.0-alpine image reports the same Node and npm as the question:
6.18.33.2-microsoft-standard-WSL2
v20.3.0
libuv 1.45.0
9.6.7npm install esbuild@0.18.1 (the esbuild version in the GitHub reports) succeeded three times out of three under the default seccomp profile, and three times out of three with --security-opt seccomp=unconfined:
added 2 packages in 856ms
0.18.1Two things explain why. First, the kernel is far newer than the 5.15.78 to 5.15.98 window that libuv PR #4059 names. Second, Docker no longer lets the container use io_uring at all. Tracing the io_uring_setup syscall while Node 20.3.0 reads one file, then counting the return values, gives this under the default profile:
6 = -1 EPERM (Operation not permitted)The same check inside a docker build RUN step gives the same refusal:
#6 0.265 6 = -1 EPERM (Operation not permitted)That is moby PR #46762, which removed the io_uring_* syscalls from the default seccomp allowlist in Docker Engine 25.0.0. The 2023 reporters were on Engine 24.0.2, per the docker version output in esbuild issue #3156. When the profile allows it (seccomp=unconfined), Node 20.3.0 does get its rings, two on each of three threads, six file descriptors in all:
1 = 12
1 = 13
1 = 18
1 = 19
1 = 4
1 = 5With UV_USE_IO_URING=0 added to that same unconfined run, the count is empty: Node 20.3.0 makes no io_uring_setup call at all. So the switch the reporters used really does take io_uring out of the picture. The full commands and output are in the reproduction log for this page.
Fix#
1. Move to a base image that does not use io_uring for file operations#
The Node.js 20.11.1 security release (CVE-2024-22017) changed libuv's io_uring on the 20.x line "from opt-out (through UV_USE_IO_URING=0) to opt-in (through UV_USE_IO_URING=1)", in the words of the commit. Node 20 itself reached end of life on 30 April 2026 according to the release schedule, so pin a current LTS line rather than a late 20.x:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildNode 22 gets there by a different route. The libuv 1.49.0 release contains "disable SQPOLL io_uring by default", and its ChangeLog entry also lists "create io_uring sqpoll ring lazily". In the copy bundled with Node 22 (source), the SQPOLL ring that file operations are submitted to is created only "if the loop is configured with UV_LOOP_USE_IO_URING_SQPOLL and the UV_USE_IO_URING environment variable is unset or a positive number". The switch is an event-loop option, not the environment variable. The trace agrees: on node:22-alpine (22.23.3) the only rings set up have no SQPOLL flag, both by default and with UV_USE_IO_URING=1. Those rings serve epoll batching, which the source comment says "should work fine".
2. Stuck on a 20.3 to 20.11.0 image: turn io_uring off before npm runs#
Set the variable as an ENV line so it reaches every later RUN and the running container, not just one shell:
FROM node:20.3.0-alpine
ENV UV_USE_IO_URING=0
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ciThe Node 20 CLI documentation lists the variable but warns that it "is implemented by a dependency of Node.js and may be removed in future versions of Node.js". Treat it as a stopgap until the image moves. The v22.x and v24.x CLI pages no longer document it.
3. Last resort: skip the install script#
The esbuild maintainer suggested this in issue #3156: run npm with --ignore-scripts so install.js never runs. The drawback he gave is that "the esbuild command will be slower" because the JavaScript shim is never replaced by the native binary. Be aware that the flag skips the install scripts of every package, not only esbuild, so a dependency that compiles a native addon will not be built:
npm ci --ignore-scriptsWhat the other answers did#
The highest-voted answer pinned node:20.2.0-bullseye-slim, the last image before the libuv upgrade. It worked in 2023 and is now an end-of-life Node release with no security fixes, so prefer step 1. Another answer reported that turning off "Use Rosetta for x86/amd64 emulation" in Docker Desktop on an M1 Mac stopped the error; nobody in the linked threads explained a mechanism for that, so take it as a report from one setup. For why emulated cross-architecture builds are slow in general (arm64 under QEMU on amd64 runners), see slow Docker ARM64 multi-platform builds.
Verify the fix#
Check what the image you actually build from ships, not what the tag suggests:
docker run --rm node:22-alpine node -p "process.version + ' libuv ' + process.versions.uv"Then make the build prove the binary runs, right after the install, so a regression fails the build instead of the container:
RUN npm ci && ./node_modules/.bin/esbuild --versionFor a stronger check on a runner you do not control, trace io_uring_setup the same way this page did. A file-operation ring shows up as a call with flags=IORING_SETUP_SQPOLL:
docker run --rm --cap-add SYS_PTRACE node:22-alpine sh -c 'apk add --no-cache -q strace >/dev/null && strace -f -e trace=io_uring_setup node -e "require(\"fs\").readFile(\"/etc/hostname\", () => {})"'One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
The same bug without esbuild: Text file busy, exit code 126#
On the same images the failure often did not mention esbuild. In docker-node#1912 the first report was yarn build failing with:
#0 0.325 /usr/bin/env: 'node': Text file busy
#0 0.334 error Command failed with exit code 126.This is the same cause and the same fix: a script's #!/usr/bin/env node line is an execve too. Two more variants are worth knowing:
- Node 18 had it briefly. Node 18.18.0 shipped the libuv 1.45.0 and 1.46.0 updates, and 18.18.1 reverted them, listing "18 and 20 node images give error - Text file busy (after re-build images)" among the regressions. Avoid a
node:18.18.0pin for that reason. - It persisted on some kernels after the first libuv workaround. The Node 20.4.0 release upgraded to libuv 1.46.0, which contains the PR #4059 workaround, yet reporters in docker-node#1912 building with kaniko inside Kata containers, on a non-longterm kernel, still failed. libuv answered with PR #4141, which disables io_uring close "on non-longterm kernels between 5.16.0 (non-longterm) and 6.1.0 (longterm)". That is another reason to rely on the 20.11.1 default rather than on a particular 20.x patch.
- Outside Docker, on Arch Linux. In 2024 people on the Arch 6.9.x kernels reported the same message from Node 22; the reporter linked it to Arch building Node against the system libuv, which keeps io_uring on. That report, nodejs/node#53051, was closed as not planned.
If your workaround was to install dependencies on the host and copy node_modules into the image, the next failure is usually a native binary built for the wrong system, such as Exec format error when loading a library in Docker Compose. Next.js has the same class of problem with its compiler, covered in Failed to load SWC binary: in both cases the fix is to run the install inside the image that will run the code.
ETXTBSY from esbuild's installer was never about esbuild or your lockfile. A Node release enabled a faster I/O path, an older kernel mishandled it under Docker, and the binary the installer had just placed could not be executed. A current Node LTS image, or Docker's own seccomp default, takes io_uring out of the picture. Keep UV_USE_IO_URING=0 only as a temporary bridge on an old Node 20 tag.
Further reading: Fix "esbuild-linux-64" could not be found: Vite in Docker. Also see Fix RangeError: Invalid time zone specified in Node.js. Also see Fix Docker npm EBADENGINE: Unsupported engine. Also see Fix curl getaddrinfo() thread failed to start in Docker. Also see Fix: unable to determine transport target for pino-pretty.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Docker npm EBADENGINE: Unsupported engine
Read the Required and Actual lines, distinguish npm self-upgrades from application installs, and stop a floating npm release breaking an unchanged Dockerfile.
Fix EACCES: permission denied, mkdir node_modules/.cache
Reproduced in Docker: why the cache directory cannot be created, the one-line Dockerfile move that fixes it, and why an old volume keeps the error alive after a rebuild.
Fix npm EACCES: permission denied on package-lock.json
Separate a local lockfile permission failure from cache and global-install errors, then fix the owner without changing the dependency tree.


