mac-to-mac file system: NFS is faster than native SMB for dev env


i bought a Mac Mini like a lot of people did for “openclaw” (lol). last year i also over-sold the value of local LLMs for a few days and spent a few grand for no reason. but it worked out: with subagents + heavier MCP clients, the Mac Mini became my always-on local build machine, and i mostly “vibe code” from my MacBook into it.

the problem: macOS file sharing via SMB is ok for big files, but it’s painfully slow for dev trees full of tiny files (TypeScript, config, node_modules). i switched the “live filesystem” part to NFS and tuned it until it was both fast and reboot-safe.

also: this version avoids triple-backtick code fences on purpose, because Craft.do can sometimes “drop” a fence mid-page and then bash comments like # ... get parsed as headings.

why NFS over SMB (for this workload)

SMB on macOS can be rough on small-file workloads because:

Finder and friends do extra metadata work (including extended attributes)

SMB has more per-operation overhead (and macOS’s implementation doesn’t always feel optimized for “60k tiny files”)

directory listings can devolve into a lot of little round trips

NFS is simpler. on my LAN, tuned NFS made “listing big dirs” go from “why is this taking forever” to “ok, usable”.

the setup i tested

server: Mac Mini (apple silicon), wired LAN, 192.168.1.200

client: MacBook (macOS), same network

workload: ~60,000 files / ~8GB, mostly TypeScript + config + node_modules

rtt: ~4ms (Wi‑Fi → switch → ethernet)

server (Mac Mini): exports + nfsd tuning

/etc/exports

other
/Users/yigitkonur -alldirs -mapall=501:20 -network 192.168.1.0 -mask 255.255.255.0

what matters here:

-alldirs: lets you mount subdirectories, not only the export root

-mapall=501:20: maps all client access to a single local uid/gid (nice for single-user dev)

-network ... -mask ...: restricts access to your local subnet

note: 501:20 is common on macOS (first user + staff), but not guaranteed. if you reuse this, swap it for your own uid/gid.

/etc/nfs.conf (server)

other
nfs.server.mount.require_resv_port = 0
nfs.server.require_resv_port = 0
nfs.server.nfsd_threads = 16
nfs.server.async = 1
nfs.server.fsevents = 0
nfs.server.wg_delay = 0
nfs.server.wg_delay_v3 = 0
nfs.server.reqcache_size = 512
nfs.server.request_queue_length = 512
nfs.server.export_hash_size = 256
nfs.server.tcp = 1
nfs.server.udp = 0
nfs.server.user_stats = 0
nfs.server.bonjour = 0
nfs.server.verbose = 0

quick “why” table:

parameterdefaultvaluewhy
require_resv_port10macOS clients often mount from non-privileged ports; this avoids silent mount pain
nfsd_threads816more concurrency for lots of small file ops
async01faster writes; fine for dev where git is the source of truth
fsevents10reduces server-side overhead
wg_delay / wg_delay_v31000 / 00 / 0lowers latency for small-file writes
reqcache_size64512better duplicate-request handling under retransmits
request_queue_length128512avoids queue bottlenecks under bursts
export_hash_size64256faster export lookup under load
bonjour10i connect by IP anyway

client (MacBook): automount + mount options + client tuning

/etc/auto_nfs

other
/Volumes/yigitkonur -vers=3,tcp,rw,hard,intr,async,noresvport,nfc,locallocks,nonegnamecache,rsize=1048576,wsize=1048576,readahead=16,noatime,retrans=5,timeo=30,actimeo=10,deadtimeout=600,rdirplus 192.168.1.200:/Users/yigitkonur

/etc/auto_master

add this at the end (without it, auto_nfs is ignored):

other
/-			auto_nfs

/etc/nfs.conf (client)

other
nfs.client.access_for_getattr = 1
nfs.client.nfsiod_thread_max = 32
nfs.client.allow_async = 1
nfs.client.is_mobile = 0
nfs.client.access_cache_timeout = 60
nfs.client.statfs_rate_limit = 10
nfs.client.tcp_sockbuf = 16777216
nfs.client.readlink_nocache = 2
nfs.client.max_async_writes = 128
nfs.client.initialdowndelay = 2
nfs.client.nextdowndelay = 4
nfs.client.iosize = 1048576

mount options (what actually mattered)

optionwhy
vers=3NFSv3 is the most stable on macOS. NFSv4 (macOS supports 4.0, not 4.1) has been flaky across releases
hard,intrhard mounts don’t fail with random i/o errors; intr lets you ctrl+c stuck ops
asyncallows buffering writes (needs nfs.client.allow_async = 1)
noresvportcommon “why won’t NFS mount on macOS” fix
nfcunicode normalization correctness on macOS
locallocksavoids NLM overhead + stale lock weirdness
nonegnamecacheavoids phantom ENOENT when files are created/deleted frequently
rsize / wsizebigger buffers = fewer trips for big reads/writes
noatimeavoids a write RPC on reads
actimeo=10fewer metadata RPCs; still reasonable freshness for dev
deadtimeout=600don’t declare the server dead too quickly (reboots happen)
rdirplusbig one for large dirs: fetch attrs with directory entries

client nfs.conf (the biggest win)

parameterdefaultvaluewhy
nfs.client.access_for_getattr01biggest perf win i found: merges permission checks into getattr calls (less RPC spam)
nfs.client.nfsiod_thread_max1632more concurrent i/o for lots of small files
nfs.client.allow_async01makes the async mount option actually do something
nfs.client.is_mobileauto0prevents macOS from auto-unmounting “unresponsive” volumes on laptops

optional macOS overhead to disable

other
# stop creating .DS_Store files on network shares
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE
# disable Spotlight indexing on the NFS volume
sudo mdutil -i off /Volumes/yigitkonur
# disable quarantine/gatekeeper checks on network files (optional)
defaults write com.apple.LaunchServices LSQuarantine -bool NO

security note (because it matters): NFSv3 with sec=sys is basically “uid/gid auth”. no encryption, no signing, no kerberos. keep it on a trusted LAN.

making it reboot-safe (so it stops being a “works until it doesn’t” setup)

server side (Mac Mini)

enable nfsd:

other
sudo nfsd enable

optional: a tiny watchdog (cron is boring and reliable)

other
# /usr/local/bin/nfsd-watchdog.sh
#!/bin/bash
if ! pgrep -x nfsd > /dev/null 2>&1; then
    nfsd enable && nfsd start
fi
if ! showmount -e localhost 2>/dev/null | grep -q "/Users/yigitkonur"; then
    nfsd update
fi

root crontab:

other
* * * * * /usr/local/bin/nfsd-watchdog.sh >> /tmp/nfsd-watchdog.log 2>&1
@reboot sleep 10 && /usr/local/bin/nfsd-watchdog.sh >> /tmp/nfsd-watchdog.log 2>&1

client side (MacBook)

a LaunchDaemon that checks every 30s and remounts if things go stale:

other
<!-- /Library/LaunchDaemons/com.supercmd.nfs-reconnect.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.supercmd.nfs-reconnect</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/nfs-reconnect.sh</string>
    </array>
    <key>StartInterval</key>
    <integer>30</integer>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/nfs-reconnect.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/nfs-reconnect.log</string>
</dict>
</plist>

and the reconnect script:

other
# /usr/local/bin/nfs-reconnect.sh
#!/bin/bash
MOUNT_POINT="/Volumes/yigitkonur"
NFS_SERVER="192.168.1.200"
EXPORT_PATH="/Users/yigitkonur"
# already mounted and working? done.
if mount | grep -q "$MOUNT_POINT" && ls "$MOUNT_POINT" > /dev/null 2>&1; then
    exit 0
fi
# server reachable?
if ! ping -c 1 -t 2 "$NFS_SERVER" > /dev/null 2>&1; then
    exit 0
fi
# force unmount stale mount
if mount | grep -q "$MOUNT_POINT"; then
    umount -f "$MOUNT_POINT" 2>/dev/null
    sleep 1
fi
mkdir -p "$MOUNT_POINT"
automount -vc 2>/dev/null
# fallback to manual mount if automount didn't pick it up
if ! mount | grep -q "$MOUNT_POINT"; then
    mount -t nfs -o vers=3,tcp,rw,hard,intr,async,noresvport,nfc,locallocks,nonegnamecache,rsize=1048576,wsize=1048576,readahead=16,noatime,retrans=5,timeo=30,actimeo=10,deadtimeout=600,rdirplus \
        "$NFS_SERVER:$EXPORT_PATH" "$MOUNT_POINT"
fi

the big lesson: don’t rsync through the NFS mount

this surprised me at first, but the math is pretty unforgiving for small files:

other
LOOKUP → CREATE → WRITE → COMMIT = 4 RPCs × 4ms RTT ≈ 16ms minimum per file

for 60,000 files, you’re paying minutes of pure protocol overhead before you even count real work. for bulk transfers, stream it instead:

other
tar cf - --exclude='.git' --exclude='.DS_Store' -C /local/project . \
  | ssh mini "tar xf - -C ~/remote/project/"

for me: 60,000 files in 1 min 57 sec. rsync-over-nfs was not close.

i wrapped it as a helper:

other
# /usr/local/bin/nfs-sync.sh
#!/bin/bash
# usage: nfs-sync.sh <local-dir> <remote-relative-dir>
LOCAL_DIR="${1:?Usage: nfs-sync.sh <local-dir> <remote-dir>}"
REMOTE_DIR="${2:?Usage: nfs-sync.sh <local-dir> <remote-dir>}"
FILE_COUNT=$(find "$LOCAL_DIR" -not -path '*/.git/*' -not -name '.DS_Store' | wc -l | tr -d ' ')
echo "syncing $FILE_COUNT files: $LOCAL_DIR → mini:~/$REMOTE_DIR"
ssh mini "mkdir -p ~/$REMOTE_DIR"
tar cf - --exclude='.git' --exclude='.DS_Store' -C "$LOCAL_DIR" . \
  | ssh mini "tar xf - -C ~/$REMOTE_DIR/"

benchmark snapshot (what changed)

i reran the same tests as i tuned config:

testoriginalafter tuningafter researchtotal gain
create 1000 files (1–10KB)101.7s (9/s)84.3s (11/s)61.9s (16/s)+78%
read 1000 files25.1s (39/s)20.2s (49/s)10.3s (97/s)+149%
stat 1000 files2.8s (363/s)1.9s (533/s)1.9s (535/s)+47%
overwrite 500 files31.3s (15/s)18.8s (26/s)10.5s (47/s)+213%
ls -la (1000 files)42.3s7.5s4.4s9.6x

biggest contributors for me:

nfs.client.access_for_getattr = 1

switching from soft to hard,intr (stability)

actimeo=10 + rdirplus (metadata efficiency)

is_mobile=0 (persistence on a laptop)

NFSv3 vs NFSv4 on macOS: i wouldn’t bother with v4

my quick take after reading docs + community reports:

macOS supports NFSv4.0, not 4.1

vers=4.1 often falls back to v3 anyway

vers=4 has had regressions on some Sonoma / Sequoia builds

tuned v3 is already very good for dev workloads

NFS vs SMB vs alternatives

protocolsmall file perfsetup complexitypersistencemacOS support
NFS (tuned)goodmediumexcellent with watchdogstable with v3
SMBmeh for small fileseasybuilt-in reconnectsupported, but can be slow
SSHFSmoderateeasydepends (FUSE)project status varies
Mutagenoften strongmediumgoodactive development
Syncthingasync synceasyexcellentgood

the perf test script i used

drop this in /tmp/nfs-perftest.sh:

other
#!/bin/bash
set -e
NFS_TARGET="/Volumes/yigitkonur/dev/my-tauri-apps/tauri-vibescroll"
TEST_DIR="$NFS_TARGET/.nfs-perftest-$$"
t() { perl -MTime::HiRes -e 'print Time::HiRes::time()'; }
log() { printf "  %-35s %7.2fs  %s\n" "$1" "$2" "$3"; }
cleanup() { rm -rf "$TEST_DIR" 2>/dev/null; }
trap cleanup EXIT
mkdir -p "$TEST_DIR"
echo; echo "  NFS perf test"; echo "  $(printf '%.0s-' {1..45})"
t0=$(t)
for i in $(seq 1 100); do dd if=/dev/urandom bs=$((1024+RANDOM%9216)) count=1 of="$TEST_DIR/f$i" 2>/dev/null; done; sync
d=$(echo "$(t) - $t0" | bc); log "create 100 files (1-10KB)" "$d" "$(echo "100/$d"|bc) files/s"
t0=$(t)
for f in "$TEST_DIR"/f*; do cat "$f">/dev/null; done
d=$(echo "$(t) - $t0" | bc); log "read 100 files" "$d" "$(echo "100/$d"|bc) files/s"
t0=$(t)
for f in "$TEST_DIR"/f*; do stat -f "%z" "$f">/dev/null; done
d=$(echo "$(t) - $t0" | bc); log "stat 100 files" "$d" "$(echo "100/$d"|bc) ops/s"
t0=$(t)
for i in $(seq 1 50); do echo "mod $i $(date +%s%N)">"$TEST_DIR/f$i"; done; sync
d=$(echo "$(t) - $t0" | bc); log "overwrite 50 files" "$d" "$(echo "50/$d"|bc) files/s"
t0=$(t); ls -la "$TEST_DIR">/dev/null
d=$(echo "$(t) - $t0" | bc); log "ls -la (100 files)" "$d"
t0=$(t); dd if=/dev/zero of="$TEST_DIR/big" bs=1048576 count=10 2>/dev/null; sync
d=$(echo "$(t) - $t0" | bc); log "write 10MB sequential" "$d" "$(echo "10/$d"|bc) MB/s"
echo "  $(printf '%.0s-' {1..45})"; echo

appendix: if you post to Craft.do via API (auto-make markdown safe)

if you have a “github-flavored” version with triple-backtick code fences, you can convert it to Craft.do-safe markdown by turning fenced code blocks into indented code blocks before you POST.

here’s the jq filter (same idea as this post: no fences, no backticks required in the output):

other
def craft_safe_md:
  gsub("\r\n"; "\n")
  | (split("\n")) as $lines
  | reduce $lines[] as $line (
      {out: [], in_code: false};
      if ($line | test("^[[:space:]]*

use it on the markdown string you’re about to send (single-pass jq; don’t round-trip through shell variables).

tldr

if SMB feels slow for small files on macOS, try NFSv3

on the client: hard,intr + noresvport + rdirplus + actimeo=10

on the client: nfs.client.access_for_getattr = 1 was the single biggest win

disable .DS_Store creation on network volumes + stop Spotlight indexing on the mount

make it reboot-safe with automount + a simple reconnect loop

don’t rsync through the mount for bulk copies; use tar | ssh