Speeding Up node_modules Bind Mounts on macOS
An npm ci, jest run, or webpack build that finishes in eight seconds on Linux takes two or three minutes inside the same container on a Mac, because every one of the tens of thousands of tiny files under node_modules is being read across the Docker Desktop virtualization boundary one round-trip at a time. This is the highest-cost variant of the mount-tuning work described in Volume Mounting & Hot-Reload Optimization, the volume topic under the broader Docker Compose environment patterns work, and unlike the hot-reload event problem it is not about lost notifications — it is about raw read throughput on a directory with an extreme file count.
The fix is to stop projecting node_modules from the host at all. On macOS the Linux container does not run on the metal; it runs inside a lightweight VM, and your source directory is shared into that VM over VirtioFS (or the older gRPC-FUSE/osxfs). Reads of a bind-mounted path are serviced on demand across that boundary, and a dependency tree with 40,000 small files turns a build into 40,000 boundary crossings. By keeping the source bind-mounted for edits but holding node_modules in a named volume that lives on the VM's native ext4 filesystem, you cut the per-file latency to near zero. This page shows how to measure the penalty, apply the named-volume override, and fall back to :delegated/:cached mount modes where a shared tree is unavoidable.
Diagnostic
Measure the read cost directly. Time a full stat-walk of the mounted dependency tree from inside the container, then compare it to the same tree copied onto the container's own filesystem:
#!/usr/bin/env bash
# how long does it take to walk node_modules across the mount?
set -euo pipefail
docker compose exec app sh -c '
echo "file count:"; find /app/node_modules -type f | wc -l
echo "walk across the bind mount:"; time find /app/node_modules -type f -exec cat {} + > /dev/null
'
# BAD: a bind-mounted tree on macOS VirtioFS
file count:
41883
walk across the bind mount:
real 1m54.207s
user 0m1.884s
sys 0m9.640s
The telling numbers are the ratios: user and sys time are tiny, but real is nearly two minutes. That gap is pure I/O wait — the container process is blocked waiting for each read to complete across the VM boundary. On native Linux the same walk finishes in a couple of seconds because the reads hit the page cache directly with no boundary to cross. Now confirm the penalty is the mount and not the disk by running the identical walk against a path that is not bind-mounted:
#!/usr/bin/env bash
# same walk, but against a container-local copy
set -euo pipefail
docker compose exec app sh -c '
cp -a /app/node_modules /tmp/nm_local
echo "walk on container-local ext4:"; time find /tmp/nm_local -type f -exec cat {} + > /dev/null
'
# container-local: same files, no boundary
walk on container-local ext4:
real 0m3.911s
user 0m1.790s
sys 0m2.104s
A walk that drops from 114 seconds to under 4 seconds simply by moving the same bytes off the shared mount is the signature of the VirtioFS penalty. The user time is nearly identical in both runs — the CPU work is the same — so the entire difference is boundary I/O. That asymmetry is what tells you the fix belongs at the mount layer, not in your build tooling or your dependency list.
Root cause
macOS cannot run Linux containers natively, so Docker Desktop runs them inside a virtual machine. Your project directory is shared from the host into that VM by a file-sharing protocol — VirtioFS on current releases, gRPC-FUSE or the legacy osxfs on older ones. A bind mount (./:/app) means every filesystem operation the container performs on that path is proxied across the host-to-VM boundary and resolved against the real file on the Mac's APFS disk. Bandwidth across that channel is fine; the problem is per-operation latency. Each open, stat, and read carries a small fixed overhead, and Node's module resolution algorithm is pathological for it: importing one package triggers a cascade of stat calls walking up the directory tree looking for node_modules, plus reads of package.json and every transitive file. A cold npm ci writes tens of thousands of files across the boundary; a jest or webpack run reads them all back the same way.
A named volume sidesteps this entirely because it does not live on the host. When you declare a Docker named volume and mount it at /app/node_modules, Docker allocates storage inside the VM's own filesystem and mounts it over that subtree, shadowing whatever the bind mount would have projected there. Reads and writes to node_modules now hit the VM's native ext4 with no boundary crossing, while the rest of /app stays bind-mounted so your source edits still sync for hot reload. The consistency mode flags — :cached and :delegated — are the older, weaker lever: they relax read-after-write ordering across the boundary so the FUSE layer can batch and cache more aggressively, which helps somewhat under gRPC-FUSE but does nothing to remove the per-file round-trip that a named volume eliminates outright.
Resolution
- Add a named volume mounted over
node_modulesso the dependency tree lives inside the VM. The order matters: the more specific mount (/app/node_modules) must come after the broad bind mount (./:/app) so Compose layers it on top.
# docker-compose.yml
services:
app:
build: .
command: npm run dev
working_dir: /app
volumes:
- ./:/app # source: bind-mounted for hot reload
- node_modules:/app/node_modules # deps: named volume on VM ext4
ports:
- "3000:3000"
volumes:
node_modules:
- Populate the volume from the image. A freshly created named volume mounted over an empty directory stays empty, so the container's
node_modulesmust be built during the image build and copied into the volume on first mount. Docker copies the image's content into a new named volume automatically only when the volume is empty — so install in the Dockerfile:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
- Build and start so the volume seeds from the image layer. On the very first
up, Docker copies the image's/app/node_modulesinto the new named volume; subsequent runs reuse it directly.
#!/usr/bin/env bash
set -euo pipefail
docker compose build
docker compose up -d
docker compose exec app sh -c 'ls /app/node_modules | wc -l' # should be non-zero
- Re-seed the volume whenever dependencies change. Because the named volume now masks the image's
node_modules, editingpackage.jsonand rebuilding the image is not enough — the stale volume still shadows it. Drop the volume and let it re-seed:
#!/usr/bin/env bash
# after changing package.json / lockfile
set -euo pipefail
docker compose down
docker volume rm "$(docker compose config --format json | \
python3 -c 'import json,sys;print(list(json.load(sys.stdin)["volumes"])[0])')_node_modules" 2>/dev/null || true
docker compose build
docker compose up -d
- If a named volume is impractical — for example a tool that must read
node_modulesfrom the host — fall back to relaxed consistency on the bind mount instead.:delegatedtells Docker the container's view is authoritative and host reads may lag briefly, which lets the FUSE layer cache aggressively:
# docker-compose.override.yml — weaker but keeps deps host-visible
services:
app:
volumes:
- ./:/app:delegated
The choice between the two approaches, and the two consistency modes, comes down to who needs the freshest view of node_modules.
Expected output
After seeding the named volume, re-run the diagnostic walk. The tree now lives on the VM's ext4 and the read cost collapses:
# GOOD: node_modules served from the named volume
file count:
41883
walk across the mount:
real 0m4.302s
user 0m1.811s
sys 0m2.267s
docker compose exec app sh -c 'time npm ci'
# real 0m19.4s (was 3m10s on the bind mount)
The real time now tracks CPU and disk work rather than boundary latency, and it lands within a few seconds of a native Linux run. The measured effect across the three configurations is stark — the bar chart below shows the same npm ci on the same lockfile under a plain bind mount, a :delegated bind mount, and the named volume.
Prevention
- Ship the named-volume mount in a committed
docker-compose.override.yml(or the base file) so every macOS developer inherits it without manual setup, and keep the volume declaration alongside it. Because Compose deep-merges the override automatically ondocker compose up, the fast path is the default and no one has to remember a flag. - Enable VirtioFS in Docker Desktop and make it a documented onboarding step. VirtioFS is substantially faster than the legacy gRPC-FUSE and osxfs backends for exactly this small-file workload, and on recent Docker Desktop it is the default — but a machine upgraded from an old install may still be on gRPC-FUSE. Assert the backend in a bootstrap check so a slow machine fails loudly.
#!/usr/bin/env bash
# onboarding assertion: warn if node_modules is still bind-mounted from the host
set -euo pipefail
mount_type=$(docker compose exec -T app sh -c \
'stat -f -c %T /app/node_modules 2>/dev/null || stat -f /app/node_modules | awk "/Type/{print \$NF}"')
if echo "$mount_type" | grep -qiE 'fuse|virtiofs|9p'; then
echo "node_modules is on a shared mount (${mount_type}) — expect slow installs" >&2
echo "add the node_modules named volume to docker-compose.override.yml" >&2
exit 1
fi
echo "node_modules on native volume filesystem: ${mount_type}"
- Document the re-seed step so a lockfile change does not silently run against a stale dependency tree. A one-line
make depstarget that runsdocker compose down && docker volume rm … && docker compose build && upremoves the footgun where a rebuilt image is masked by an old volume.
Platform caveats
macOS (Docker Desktop): the named-volume trick is the primary fix here; VirtioFS reduces per-file latency but a native volume removes the boundary entirely. Confirm VirtioFS is selected under Settings → General → file sharing implementation. WSL2: the same file-count penalty appears when the repo lives on
/mnt/cand is bind-mounted into the distro; move the project onto the Linux filesystem (~/code) instead, where a named volume is usually unnecessary. Apple Silicon (ARM64): a named volume seeded from anarm64image cannot be reused by anamd64container and vice versa — native modules compiled for one architecture will fail to load. Re-seed the volume after switching image platform.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- docker-compose.yml docker-compose.override.yml 2>/dev/null || true
docker compose down
docker compose up -d --force-recreate
Frequently Asked Questions
Why is a named volume so much faster than :cached or :delegated?
Because the consistency flags only relax ordering, not location. :cached and :delegated tell Docker it may serve slightly stale reads on one side of the boundary so the FUSE layer can batch and cache more aggressively, which trims some latency under gRPC-FUSE. But the files still live on the macOS host, so every cache miss is still a boundary round-trip. A named volume moves the data onto the VM's own ext4 filesystem, so there is no boundary to cross at all — reads hit the Linux page cache directly, exactly as they would on native Linux. That is why the named volume is roughly ten times faster while the consistency flags recover only a fraction of the gap.
My editor stopped showing types after I added the named volume. What broke?
The named volume lives inside the VM and is not projected back to the host, so your IDE — which reads files from the host disk — no longer sees any node_modules. Nothing is broken in the container; the tree simply is not on your Mac anymore. The usual fix is to run npm install once on the host purely to populate a local node_modules for editor type resolution, while the container keeps using its own copy from the volume. The two trees never need to match at runtime because the container never reads the host copy and the editor never reads the container copy.
I changed package.json but the container still uses the old dependencies. Why?
Once the named volume exists, it shadows whatever node_modules your rebuilt image contains — Docker only copies image content into the volume when the volume is empty, which is just the first run. Every subsequent up reuses the existing volume and ignores the fresh image layer. You must remove the volume so it re-seeds from the new image, or run npm ci inside the running container to update the volume in place. This is the single most common surprise with the named-volume pattern, so wrap the down / remove / rebuild sequence in a make deps target.
Does this help on native Linux, or is it macOS-only?
On native Linux a bind mount has no VM boundary — the container reads your host files through the kernel's page cache directly — so node_modules over a plain bind mount is already near-native speed and the named volume buys you nothing measurable. The technique is specifically a workaround for the virtualization layer that Docker Desktop uses on macOS and Windows. Keeping the named volume in a docker-compose.override.yml rather than the base file means Linux developers can opt out cleanly while macOS developers get the speedup, though a shared named volume also does no harm on Linux beyond the re-seed step.