Repository navigation
Description
Summary
Replace chokidar with Bun's built-in fs.watch(root, { recursive: true }) and delete the ~250 lines of workaround machinery that exist only to survive chokidar defects. This removes peruse's only runtime dependency.
Research date 2026-09-22, Bun 1.4.2, Linux 6.8. The full research report (web findings with URLs, empirical harness results, feasibility table, recommendation) is attached at the bottom of this issue under Full report.
What we know
The July 2026 rationale for chokidar is void
docs/design-history.mdchose chokidar because Bun'sfs.watchhad "known event-reliability gaps" (oven-sh/bun discussion 1571) and said "revisit if Bun's watcher matures".- Bun 1.3.14 (2026-05-13) rewrote the POSIX watcher to talk to inotify / FSEvents / kqueue directly; Bun 1.4 (2026-08-20) moved it to Rust (
src/runtime/node/path_watcher.rs). Fixed in that line: recursive pickup of directories created afterwatch(), delete-and-recreate going inert, atomic-save (temp + rename) reporting only the temp name, delete/recreate, dead-after-close-then-rewatch, EACCES crash. Bun 1.4 adds anerrorevent for unwatchable subtrees (ENOSPC/EACCES) and emits('change', null)on inotify queue overflow. - Discussion 1571's last complaint predates the rewrite by 19 months; the specific event-dropping issues (#36328, #39070, #7919, #42570, #15939) are closed as completed.
Measured on this machine (Bun 1.4.2, chokidar 4.0.3)
Mixed burst, 201 dirs / 4,001 files, 1,940 operations across 10 categories (create, modify, atomic rename-over, delete, new dir, file in new dir immediate and delayed, deep nested dir, symlink to dir, recursive rmdir):
Bun fs.watch |
chokidar 4.0.3 | |
|---|---|---|
| operations missed | 0 / 1,940 | 0 / 1,940 |
| inotify watches after start | 202 | 4,203 |
| extra file descriptors | 0 | 0 |
| readiness | none needed | 219 ms |
Scale and edge cases:
| Probe | Bun fs.watch |
chokidar 4.0.3 |
|---|---|---|
| real peruse repo (606 dirs, 7,774 files incl. node_modules and .git): watches / startup | 606 / 15 ms | 8,380 / 356 ms |
| 4,000-dir, 20,000-file tree: watches / startup | 4,041 / 85 ms | 24,150 / 1,237 ms |
| 109 writes raced against startup | 109 caught | 109 caught |
| 30,000 writes during a blocked event loop | 100% | 100% |
.git HEAD / refs / index events, relative paths |
yes | yes |
| scan-breaking ENOTDIR symlink (issue #17) | full coverage, no stall | ready never fires, directory silently unwatched (#17 reproduced) |
directory replaced via rename() |
caught | missed entirely |
| timestamp-preserving in-place rewrite (documented blind spot in ARCHITECTURE) | caught | caught |
cyclic symlink a/b/loop -> root |
survives | survives |
| 12 concurrent watchers | 1 inotify instance, 0 extra fds | same |
writes after close() |
0 events, no leak | same |
Corrections to our own documentation
server/index.js:589and ARCHITECTURE say chokidar "holds fds per watched file under Bun". On Linux this is wrong. chokidar registers one inotify watch per file, but all watches live inside one inotify instance costing one fd: 8,380 watches, 0 extra fds on the real repo. The per-entry fd cost is a macOS kqueue property, which the comments atserver/index.js:516and:556describe correctly. Consequence: on Linux the fd-derived watch budget (PERUSE_WATCH_BUDGET, ⅛ ofulimit -n) governs the wrong limit; the binding resource isfs.inotify.max_user_watches.- The Directories containing a dangling symlink silently lose live updates #17 stall (ENOTDIR link leaves
readypending forever and silently unwatches a directory) is a chokidar bug. Bun's watcher is immune.
Bun fs.watch semantics we rely on (pin each with a test)
- Resource model: one process-global inotify fd + one reader thread; one watch per directory; watchers dedupe by realpath;
inotify_rm_watchon last owner detach. recursive: trueon Linux is emulated natively: walks at subscribe time, adds a wd onIN_CREATE|IN_ISDIR/IN_MOVED_TO, synthesizesrenamefor entries of a new subtree.- Atomic save:
rename:<temp>thenrename:<target>(matches Node). - Deleted watched root: two
renameevents with the root basename, then a<null>/undefinedfilename. - Symlinks: root is realpath-resolved; the recursive walk skips symlink entries (no descent, no
followSymlinksoption). Open fs.watch recursive on Linux: no event for a change behind a symlink entry that points outside the tree (regression in 1.3.14) oven-sh/bun#43065 / PR #43090 would start reporting link entries asrenameof the link itself, still never descending. - No ready/initial-scan concept; no
awaitWriteFinish; a 1 ms per-handler duplicate suppressor (same path + same type within 1 ms is dropped). ignorepredicate (Bun 1.4): filters events only. Verified 2026-09-22: 81 inotify watches with and without anignoreexcluding half the tree. Watches are installed on gitignored dirs regardless.- Recursive-root self-events arrive as
changewithfilename === undefined(fs.watch on Linux: an event about the watched directory itself differs from Node (event type, filename for a trailing slash, recursive root) oven-sh/bun#43066): treat as whole-tree invalidation.
Known risks and their guards
- No install-time ignore. A recursive watch registers
node_modulesand.git/objects. On this repo that is 606 watches instead of ~16, trivial; on a 30k-directory JS monorepo against a stockmax_user_watchesof 8192 it is exhaustion. Guard: handle the ENOSPCerrorevent with a message namingfs.inotify.max_user_watches. Escape hatch if that proves insufficient is not chokidar but our own traversal callingfs.watch(dir, { recursive: false })on admitted directories (~120–180 lines, full ignore control, still no dependency). Do not build pre-emptively; measure first. - Queue overflow untested here (host queue is 64x default). Guard: treat a null/undefined filename as "invalidate the whole tree" (cheap: SSE clients already re-fetch the tree on any event) and add a regression test on a stock-configured machine.
- Active churn. Four
fs.watchPRs opened in Bun the week of 2026-09-17 (#43064, #43083, #43090, #43715). Guard: add anenginesfloor (bun >= 1.4.0, for theerrorevent semantics) and pin each relied-upon behavior with a test, proven RED first per project practice. - macOS unverified. Bun's rewrite moved macOS to FSEvents-only, which should remove the per-path fd cost behind the fd-exhaustion saga. Guard: run the scratch harnesses (
harness.js,edges.js,stress.js, attached in the report's artifact list) unchanged on a Mac before release. - ENOSPC could not be reproduced here (user namespaces blocked). Guard: same macOS/stock-Linux verification pass.
Not recommended: keeping chokidar (20x kernel watches, keeps the dependency and ~250 lines of workaround, still carries the #17 stall and the rename-swap miss) or a hybrid (doubles watch count and failure surface; the fallback machinery compensates for chokidar, and without chokidar there is nothing to compensate for).
How we execute
Per CLAUDE.md the plan lives here, not in a repo document. Implementation and review by different agents/sessions; every regression test proven RED against the chokidar implementation before it is trusted.
Phase 1: pin the contract with tests (RED first)
-
engines: { "bun": ">=1.4.0" }in package.json; startup check inbin/peruse.jswith a clear message. - Integration tests for: recursive pickup of a directory created after watch; file inside it immediately and delayed; atomic rename-over reports the target;
.git/HEAD, refs, index events setgit: trueand are not forwarded as file events;.git/objectsfiltered; gitignored-dir events filtered viaskipRel; undefined filename ⇒ whole-tree invalidation;errorevent with ENOSPC ⇒ rate-limited log namingfs.inotify.max_user_watches, never fatal; cleanclose()with zero events after; the Directories containing a dangling symlink silently lose live updates #17 ENOTDIR link shape keeps full coverage of siblings. - Decide which existing tests in
test/unit/scanbreaking.test.jsandtest/integration/watch-recovery.test.jsbecome "Bun handles this" tests and which are deleted with the subsystem.
Phase 2: swap the watcher
-
fs.watch(root, { recursive: true, ignore })per project;readyresolves synchronously after the call while preserving theRuntimeClosedErrorcontract and the "first project request waits for readiness" behavior (now trivially satisfied). - Move
skipRelfrom chokidar'signoredinto the event callback (plus Bun'signorepredicate for cheap early filtering). - Keep the ~200 ms coalescing batches and
{changed, git}payload unchanged. -
errorhandler with the ENOSPC-specific message.
Phase 3: delete the compensation subsystem
-
SURVIVABLE_LINK_ERRORS,findScanBreakingLinks(~server/index.js:434–497). -
fallbackWatchers,fallbackSnapshots,reconcileTimers,verifyTimers,snapshotDirectory,dropFallback,scheduleVerification,reconcileFallback,scheduleReconcile,installFallback,recoverSubtree(~:597–612,:647–762). - Stall watchdog and heartbeat (~
:818–845). -
watchable/softFdgate,WATCH_BUDGETadmission set,PERUSE_WATCH_BUDGET,startServer({ watchBudget }): remove, since the recursive walk cannot be capped and the fd limit is not the binding resource on Linux. Re-evaluate theulimitre-exec inbin/peruse.jsafter the macOS run. - Remove
chokidarfromdependencies;bun.lockupdate;dependenciesbecomes empty.
Phase 4: platform verification
- Run the harnesses on macOS (Bun 1.4.x): burst, edges (E3 poisoned link, E6 deleted root), stress S1 (fds and kqueue/FSEvents behavior).
- Run on a Linux box with stock
fs.inotify.max_user_watches(8192 or 65536) against a largenode_modulesto exercise the ENOSPC path. -
bun run test:allgreen; note E2E harness: retire the Bun/Playwright pipe-stall workarounds when upstream fixes land #29 first-run e2e flake rule.
Phase 5: documentation, same commits as the code
- ARCHITECTURE.md: dependency line (no runtime dependencies), watcher section rewritten (resource model, ignore semantics, ENOSPC, undefined-filename invalidation, symlink policy), delete the recovery/watchdog/budget sections and the "known watcher limitation" paragraphs, fix the fd-per-file claim.
- CHANGELOG
[Unreleased]: Changed (no runtime dependencies; live updates no longer stall on broken symlinks; catches directory swaps and in-place rewrites), Removed (PERUSE_WATCH_BUDGET), and the Bun ≥ 1.4 requirement. Decide at release whether the engines floor makes this a major. - docs/DEVLOG.md dated entry with the measurements and the doc correction.
- README: remove any chokidar mention, add the Bun version requirement.
-
docs/design-history.mdstays historical (do not edit).
Related
- Directories containing a dangling symlink silently lose live updates #17 (closed): the stall this migration makes structurally impossible.
- Guard the recursive freshness scan against concurrent file deletion #30:
newestFileMtimerace; unrelated code path, still needed. - E2E harness: retire the Bun/Playwright pipe-stall workarounds when upstream fixes land #29: e2e harness flakes; rerun rule applies during Phase 4.
- chokidar 5.0.0 is available; moot if this lands.
Full report (research agent, 2026-09-22; scratch artifacts referenced at the end are not checked in)
Can peruse drop chokidar for Bun's fs.watch?
Research date: 2026-09-22. Machine: Linux 6.8.0-139-generic x86_64, Bun 1.4.2,
chokidar 4.0.3 (from /home/talkasab/peruse/node_modules, read-only).
Nothing inside /home/talkasab/peruse was modified; the repo was only watched
(read-only) during test E5.
Bottom line: yes — drop chokidar. Every documented reason peruse chose
chokidar in July 2026 has been retired by Bun's fs.watch rewrite in v1.3.14
(2026-05-13), and the single failure that motivated peruse's largest piece of
watcher machinery (issue #17, the symlink scan stall) is a chokidar bug that
Bun's watcher does not have. I reproduced that stall against chokidar on this
machine today and confirmed Bun is immune. Caveats and risks are in §5.
1. Machine caveat (read this before trusting the numbers)
This host has non-default inotify sysctls:
| sysctl | this machine | common kernel/distro default |
|---|---|---|
fs.inotify.max_user_watches |
1048576 | 8192 (kernel) / 65536–524288 (distros) |
fs.inotify.max_user_instances |
1048576 | 128 |
fs.inotify.max_queued_events |
1048576 | 16384 |
ulimit -n soft and hard are both 1048576.
Two consequences: the ENOSPC (watch-limit exhaustion) path could not be
exercised, and the queue-overflow test (§3, S2) is weaker than it looks because
the kernel queue here is 64x the usual size. I tried to run a probe under
unshare -Ur with fs.inotify.max_user_watches=16; user-namespace creation is
blocked on this host (unshare: write failed /proc/self/uid_map: Operation not permitted), so that probe is untested, for both watchers equally.
2. Web research
2.1 The rewrite: Bun v1.3.14, 2026-05-13
https://bun.com/blog/bun-v1.3.14
"Bun's
fs.watch()implementation on POSIX platforms has been completely
rewritten to talk directly to the OS file-watching APIs (inotify, FSEvents,
kqueue) instead of routing through Bun's internal bundler watcher."
Specific items from that release:
- Recursive new-directory pickup fixed. Previously "directories created
after the watch started were never tracked, so files inside them were
invisible to the watcher." This was the headlinerecursive: truedefect on
Linux and it is fixed. - Delete-and-recreate fixed. "deleted-and-recreated files emit
change
events again … the new inotify watch descriptor is correctly registered on
recreation." - macOS. "uses FSEvents exclusively for both files and directories, matching
libuv's behavior and halving the thread overhead." macOS no longer spins up
two watcher threads.
Directly relevant to peruse's history: the fd-exhaustion saga in
docs/DEVLOG.md:1400 ("under Bun each watched path holds fds (files too, ~2–3
each), stock macOS shells give 256") is a kqueue/macOS phenomenon. kqueue
requires one open descriptor per watched entry; FSEvents does not. Bun's move to
FSEvents-only on macOS removes that cost for Bun's own watcher.
2.2 recursive: true on Linux
Supported and emulated by Bun (inotify has no kernel recursion). Bun walks the
tree and adds one inotify watch per directory, including directories created
later. Confirmed empirically in §3 (E1, S1, R1–R3). The filename argument
arrives as a path relative to the watched root — the old basename-vs-relative
inconsistency was oven-sh/bun#29677, closed as
completed 2026-07-24. Every one of the ~2,700 distinct paths I observed came
back relative and correctly spelled, including .git/objects/f7/tmp_obj_YPgasB.
2.3 Event reliability: what is fixed, what is still open
Closed as completed (all in the last three months):
| Issue | Title | Closed |
|---|---|---|
| #42570 | a previously-watched directory path stays inert after delete+recreate (Linux) | 2026-09-20 |
| #36328 | fs.watch (Linux): atomic saves never report the rename destination |
2026-08-10 |
| #39070 | fs.watch on macOS reports only one file per ~40 ms window, so bulk changes are mostly lost |
2026-08-15 |
| #29677 | recursive: true: inconsistent filename (basename vs relative path) |
2026-07-24 |
| #7919 | fs.watch skips save events |
2026-07-24 |
#36328 and #39070 are exactly the "drops events" reputation. Both fixed.
I re-ran the #42570 repro in all four shapes (pre-watched/not ×
recursive/not) on 1.4.2: all four caught the post-recreate write (§3, R1–R3).
Still open, Linux-relevant:
| Issue | Title | Impact on peruse |
|---|---|---|
| #43065 | recursive on Linux: no event for a change behind a symlink entry pointing outside the tree (regression in 1.3.14) | This is peruse's desired behavior. peruse sets followSymlinks:false. Upstream considers it a bug; a fix would start delivering events peruse currently suppresses. Watch item, not a blocker. |
| #43066 | an event about the watched directory itself differs from Node (event type, filename for trailing slash, recursive root) | For a recursive root, Bun emits change with filename === undefined where Node emits nothing. peruse must null-guard the filename. Confirmed empirically. |
| #6955 | fs.watch goes to infinite loop with big files |
Open since 2023-11, last touched 2026-08-04. Did not reproduce in any test here. |
| #14568 | watch doesn't trigger on file writes on macOS |
Open since 2024-10, last touched 2026-08-04, i.e. before the 1.3.14 rewrite landed in that file. Probably stale; unverifiable on Linux. |
| #42936 | Windows watcher: a ReadDirectoryChangesW buffer overflow drops the changes and nothing reloads |
Windows only. Fix in PR #42951 resyncs every watched path after overflow. The report notes "the inotify backend has the same gap for IN_Q_OVERFLOW" — see §5 risk 2. |
Discussion oven-sh/bun#1571 (the citation in
docs/design-history.md:91) is resolved: the maintainer committed to a
built-in watch API in Dec 2022, implemented Jan 2024. Its most recent substantive
comment is from October 2024 — a user seeing fewer events than chokidar on
~350 Angular build outputs. That complaint predates the 1.3.14 rewrite by 19
months and is superseded by the closed issues above. The design-history
citation is now stale.
2.4 Bun 1.4 behavior change
From the 1.4 breaking-changes list,
oven-sh/bun#28792 (closed, 2026-04-02):
"recursive
fs.watch()emits'error'for subdirectories it cannot watch
instead of swallowing the failure"
(PR #36415)
This is a real gain for peruse: a subtree that cannot be watched (EACCES,
and presumably ENOSPC) now surfaces instead of vanishing. Verified: an
unreadable subdirectory produced EPERM EACCES: permission denied, watch '…/locked' on the watcher's error event while sibling coverage kept working.
Compare with the silent chokidar failure mode that peruse's issue #17
machinery exists to paper over.
2.5 Active churn (this matters)
Four fs.watch pull requests opened in the week before this research:
- #43064 report a watched symlink under its own name on Linux
- #43083 report a directory's attribute change as
renameon Linux - #43090 watch symlink entries of a recursive tree on Linux — open as of 2026-09-17. Description: "The walk now reports symlink entries … Every event on that watch is reported as a rename of the link itself, like Node. The walk never descends into a link target, so a link to an ancestor cannot loop." Targets must be regular files or directories; others (e.g.
/dev/stdout) are skipped. - #43715 run
nextTicks and microtasks between the events of one batch
#43090 would change what peruse sees for symlinks: link entries would start
reporting, though still without descending. Since peruse coalesces and re-fetches
the tree, extra events are harmless; the no-descent guarantee is what matters and
the PR preserves it explicitly.
2.6 chokidar on Bun
No chokidar-specific Bun breakage found, and none observed here. chokidar 4.0.3
runs on Bun 1.4.2 and delegates to node:fs.watch, so the two share a backend.
That is why both show one inotify instance in §3 — the difference is entirely
in how many paths each one registers.
3. Empirical results
Scripts (all in the scratchpad): harness.js (burst), edges.js (edge cases),
stress.js (scale/queue/git), recreate.js + rewatch.js (#42570 shapes),
errs.js (error surfaces). Both watchers ran the identical workload;
chokidar with { ignoreInitial: true, followSymlinks: false }, matching
server/index.js:787.
3.1 Coverage burst — 200 dirs x 20 files, 1,940 mixed operations
| Category | ops | Bun exact hits | chokidar exact hits |
|---|---|---|---|
| A create file in existing dir | 400 | 400 | 400 |
| B append to existing file | 400 | 400 | 400 |
| C atomic rename-over (temp + rename) | 300 | 300 | 300 |
| D delete existing file | 300 | 300 | 300 |
| E1 mkdir after watcher start | 100 | 100 | 100 |
| E2 file in new dir, no delay | 300 | 300 | 300 |
F1 deep new dir (deepNN/a/b/c) |
50 | 50 | 50 |
| F2 file in deep new dir, +150 ms | 50 | 50 | 50 |
| G symlink to a directory | 20 | 20 | 20 |
H rm -rf a populated directory |
20 | 20 | 20 |
| Total | 1940 | 1940 (100%) | 1940 (100%) |
No misses and no parent-only attributions on either side. Nothing was scored by
event type, only by whether the changed path was named — which is all peruse's
noteChange needs.
| Metric | Bun fs.watch |
chokidar 4.0.3 |
|---|---|---|
| readiness signal | none needed (see 3.2) | ready at 219 ms |
| burst wall time | 377 ms | 431 ms |
| raw events delivered | 4,335 | 2,510 |
| event types | rename 2887, change 1448 |
add 770, change 700, unlink 720, addDir 300, unlinkDir 20 |
| distinct paths reported | 2,710 | 2,410 |
| fds held | 9 total, +0 | 9 total, +0 |
| inotify instances | 1 | 1 |
| inotify watches after start | 202 (= 201 dirs + root) | 4,203 (= 4,001 files + 201 dirs + root) |
| inotify watches after burst | 482 | 4,513 |
Bun holds one inotify watch per DIRECTORY. chokidar holds one per FILE and per
directory — 20.8x more on this tree. Bun is chattier per event (~2.2 raw
events per operation vs 1.3) because it does not classify; peruse coalesces into
200 ms batches anyway, so that costs nothing.
Correction to ARCHITECTURE.md: the note that "chokidar holds fds per
watched file under Bun" no longer describes Linux. Under Bun 1.4.2, 4,203
chokidar watches cost zero additional file descriptors — one shared inotify
instance with 4,203 watch descriptors. The fd-per-path cost is macOS/kqueue, as
server/index.js:538 already says in its own comment ("Crucial on macOS, where
each watched directory costs a file descriptor (kqueue, default ulimit 256)").
The peruse watch budget on Linux is therefore governing the wrong resource: the
binding limit is fs.inotify.max_user_watches, not ulimit -n.
3.2 Edge cases
| Probe | Bun fs.watch |
chokidar 4.0.3 |
|---|---|---|
E1 readiness race — write into 20 deep dirs N ms after the watch call, no ready wait |
0 ms: 20/20; 1 ms: 20/20; 5 ms: 20/20; 25/100/400 ms: 20/20 | 0 ms: 0/20; 1 ms: 0/20; 5 ms: 0/20; 25 ms: 20/20; 100/400 ms: 20/20 |
| E2 symlink policy — pre-existing link to a dir inside the root and to a dir outside it | 3 watches installed; nothing reported under either link; the real path reported correctly; no descent | 4 watches; reports the link entries themselves (linkin, linkout) but does not descend |
E3 scan-breaking link (peruse issue #17) — symlink(root/regular.txt/sub, poisoned/bad), i.e. a path running through a regular file (ENOTDIR) |
readyMs n/a, watch call returned in 1 ms; healthy sibling seen: yes; poisoned dir's sibling seen: yes; no errors |
ready never fired (timed out at 8 s); poisoned/p.txt never reported; no error event. Issue #17 reproduced exactly. |
E4 cyclic symlink — a/b/loop -> root |
starts in 0 ms, 3 watches, survives, catches the write | starts in 4 ms, 4 watches, survives |
E5 real peruse repo (606 dirs, 7,774 files, incl. node_modules and .git) |
ready in 15 ms, 606 inotify watches, +0 fds, +1 instance, 0 spurious events while idle | ready in 356 ms, 8,380 inotify watches, +0 fds, +1 instance, 8 spurious events while idle |
| E5b release on close | 0 watches, 0 fds remaining | 0 watches, 0 fds remaining |
| E6 deleted watched root | emits rename:sub then rename:<null filename>; close() clean, no throw, process survives |
emits unlinkDir:sub; close() clean |
| E7 events after close | 50 writes after close() → 0 events, no leak |
0 events, no leak |
| E8 20,000 writes in one burst (3.2 s / 2.4 s) | 20,000/20,000 (100%), 40,000 events | 20,000/20,000 (100%), 20,000 events |
| E9 12 concurrent watchers | 1 shared inotify instance, +0 fds, 252 watches, all isolated and correct | identical: 1 instance, +0 fds, 252 watches |
3.3 Scale and semantics
| Probe | Bun fs.watch |
chokidar 4.0.3 |
|---|---|---|
S1 large tree — 4,000 dirs / 20,000 files; write into 109 scattered dirs immediately after the call returns (no ready wait for Bun; after ready for chokidar) |
start returns in 85 ms; 109/109 caught; 4,041 watches; +1 fd | ready at 1,237 ms; 109/109; 24,150 watches; +1 fd |
S2 blocked JS thread — external bash process writes 30,000 files while this process spins for 6 s |
30,000/30,000 (100%), 60,000 events | 30,000/30,000 (100%) |
S3 .git metadata — git checkout -b, edit, add, commit under the watcher |
.git/HEAD, .git/HEAD.lock, refs/heads/feature, logs/HEAD, .git/index, index.lock, packed-refs.lock, AUTO_MERGE.lock, .git/objects/**, plus the working file — 21 .git paths |
same coverage, 14 .git paths |
| S4 in-place rewrite with timestamps restored (same dev:ino:mode:size:mtimeNs) | detected (change:same.txt) |
detected (change:same.txt) |
| S5 ENOSPC under a tiny watch limit | not testable (unshare denied) |
not testable |
S4 is worth calling out: ARCHITECTURE.md:266 documents this shape as an
accepted blind spot of the snapshot-reconciliation fallback. inotify's IN_MODIFY
does not consult mtime, so a plain recursive fs.watch catches it. Dropping the
snapshot fallback would remove that documented limitation rather than inherit it.
3.4 Delete/recreate and directory churn
| Probe | Bun | chokidar |
|---|---|---|
R1 delete sub/, recreate at the same path, write inside |
sees recreated dir, sub/deep/b.txt, sub/c.txt |
same |
R2 real git branch churn: a tracked directory removed and restored across three git checkouts, then edited |
sees the post-churn edit and the new file | same |
R3 directory swapped by rename() (live→old, staging→live), then write into the swapped-in dir |
caught live/y.txt |
MISSED — no events at all |
| rewatch (exact #42570 shape) watch → close → rm → mkdir → watch again → write; all four combinations of pre-watched × recursive | 4/4 caught | n/a |
R3 is a chokidar defect Bun does not share, and it is a realistic shape
(blue-green output directories, mv dist.new dist).
3.5 Error surfaces (Bun 1.4.2)
| Situation | Behavior |
|---|---|
fs.watch on a missing directory |
throws synchronously, code === "ENOENT" |
| unreadable (mode 000) subdirectory inside a recursive watch | error event EPERM EACCES: permission denied, watch '…/locked'; not fatal; siblings keep reporting |
| attribute change on the recursive root itself | change with filename === undefined (issue #43066) — callers must null-guard |
close() |
synchronous, releases every watch descriptor, no post-close events |
4. Would Bun's fs.watch alone satisfy peruse?
Against the requirement list:
| peruse requirement | Bun fs.watch |
|---|---|
| per-project lazy watcher | ✅ one call per project; 12 concurrent watchers cost 1 inotify instance and 0 extra fds |
| ~200 ms coalescing | ✅ peruse already owns this; Bun's extra chattiness is absorbed |
| never follow symlinks | ✅ free today — no descent, no followSymlinks option needed. Risk: PR #43090 / issue #43065 may add link-entry reporting (still no descent) |
.git metadata events (HEAD, refs, index) |
✅ all present, with relative paths |
| ignore gitignored dirs | ignored option. Events can be filtered in the callback (peruse's skipRel already does), but the watches are installed regardless |
| bounded resource usage | |
| readiness before first response | ✅ empirically unnecessary — 109/109 raced writes caught on a 4,000-dir tree with zero delay. Resolve ready synchronously after the call returns |
| clean close | ✅ no leaks, no post-close events |
| survive poisoned symlinks (#17) | ✅ immune — this is a chokidar bug |
| skip sockets/FIFOs/devices | ✅ inotify watches the directory, not the special file; PR #43090 explicitly skips non-regular link targets |
What peruse would reimplement, and how much code
Added (roughly 25–40 lines):
- Event-level ignore filtering —
skipRelalready exists (server/index.js:618);
it just moves from chokidar'signoredcallback into thefs.watchcallback.
Near-zero new code. - Null-filename guard for the recursive-root event (issue #43066). One line.
readyresolves right after thefs.watchcall, with the existing
RuntimeClosedErrorcontract preserved. A few lines.errorhandler: same rate-limited logging as today, plus an ENOSPC-specific
message telling the user to raisefs.inotify.max_user_watches. ~15 lines.
Deleted (roughly 220–280 lines of server/index.js):
SURVIVABLE_LINK_ERRORSandfindScanBreakingLinks(~lines 434–497).- The whole recovery subsystem:
fallbackWatchers,fallbackSnapshots,
reconcileTimers,verifyTimers,snapshotDirectory,dropFallback,
scheduleVerification,reconcileFallback,scheduleReconcile,
installFallback,recoverSubtree(~lines 597–612 and 647–762). - The stall watchdog and its heartbeat plumbing (~lines 818–845).
- The
watchable/softFdgate and theWATCH_BUDGETadmission set, or a
rewrite of the budget in terms of inotify watches rather than fds. - Plausibly
bin/peruse.js'sulimitre-exec, on Linux at least.
Net: peruse gets smaller, loses its only runtime dependency, and deletes the
subsystem that has produced the most incident history in the dev log — all of
which is squarely the project's "minimum code, maximum leverage" principle.
5. Recommendation: drop chokidar, with three guards
Confidence is high on Linux, medium on macOS (untestable here).
Evidence for:
- The founding rationale is void. Discussion 1571 is resolved; its last
complaint predates the 1.3.14 rewrite by 19 months; the specific
event-dropping issues (#36328, #39070, #7919) are closed as completed. Both
watchers scored 1,940/1,940 and 20,000/20,000 here. - peruse's worst bug is a chokidar bug. Issue Directories containing a dangling symlink silently lose live updates #17 — the ENOTDIR link that
leavesreadypending forever and silently unwatches a directory — reproduced
against chokidar on this machine today (E3) and did not reproduce against Bun.
~250 lines of peruse exist only to survive it. - 20x cheaper kernel resources. 202 vs 4,203 watches on the synthetic tree;
606 vs 8,380 on the real repo; 4,041 vs 24,150 on the 4,000-dir tree. - 14x faster startup on the real repo (15 ms vs 356 ms) and no readiness
race to wait out — the first project request stops blocking on a scan. - Strictly better on two real shapes: the rename-swapped directory (R3,
chokidar missed it entirely) and the timestamp-preserving in-place rewrite
thatARCHITECTURE.mddocuments as a known blind spot. - Failures are now loud, not silent (Bun 1.4's
errorevents for
unwatchable subtrees).
Risks and the guards for them:
- No install-time ignore (the only structural regression). A recursive watch
registersnode_modulesand.git/objectswhether or not peruse wants them.
On this repo that is 606 watches instead of 16 — a 38x amplification that is
still absolutely trivial. On a large JS monorepo (30k+ directories under
node_modules) against a stockmax_user_watchesof 8192, it is exhaustion.
Guard: handle the ENOSPCerrorevent with a message naming
fs.inotify.max_user_watches. If that proves insufficient, the escape hatch
is not chokidar — it is peruse's own traversal calling
fs.watch(dir, { recursive: false })on admitted directories only
(~120–180 lines, per-directory cost, full ignore control, still no
dependency). Do not build that pre-emptively; measure first. - Queue overflow is unproven.
IN_Q_OVERFLOWhandling could not be tested
because this kernel's queue is 64x the default, and issue #42936 states the
inotify backend shares the Windows gap (where a fix landed via PR #42951).
Guard: a regression test on a stock-configured machine, and treat a
<null>/undefined filename as "invalidate the whole tree" — which is cheap,
because peruse's SSE clients already re-fetch the tree on any event. - The area is churning right now. Four
fs.watchPRs opened in the week
before this research, one of which (#43090) changes symlink-entry reporting.
Guard: declare a Bun floor.package.jsoncurrently has noenginesfield;
add>=1.3.14at minimum, preferably>=1.4.0for theerror-event
behavior, and pin each relied-upon behavior with a test — the project's own
practice of proving regression tests RED first applies here. - macOS is unverified. Bun's rewrite moved macOS to FSEvents-only, which
should remove the fd-per-path cost that caused peruse's fd-exhaustion saga.
But chokidar-on-Bun watches per file, so on macOS it may now be worse than
the measurements above suggest, not better. Guard: runharness.jsand
edges.jsunchanged on a Mac before release; they need no modification.
Not recommended: keep chokidar (pays 20x the kernel watches, keeps the
dependency, keeps ~250 lines of workaround, and still carries the E3 stall and
the R3 rename-swap miss), or a hybrid that runs both (doubles the watch
count and the failure surface for no benefit — the fallback machinery exists to
compensate for chokidar, and without chokidar there is nothing to compensate for).
URLs
- https://bun.com/blog/bun-v1.3.14 — the POSIX
fs.watchrewrite, 2026-05-13 - `Bun.watch` - fast and reliable file watching api oven-sh/bun#1571 — the
docs/design-history.md:91citation; resolved, last substantive comment Oct 2024 - List of breaking changes for 1.4 oven-sh/bun#28792 — Bun 1.4 breaking changes (closed 2026-04-02)
- fs.watch: surface inotify_add_watch subtree failures as 'error' events oven-sh/bun#36415 — surface
inotify_add_watchsubtree failures aserrorevents - fs.watch: a previously-watched directory path stays inert after delete+recreate (Linux) oven-sh/bun#42570 — delete+recreate inert (closed completed 2026-09-20); did not reproduce on 1.4.2
- fs.watch(Linux): atomic saves never report the rename destination oven-sh/bun#36328 — atomic saves never report the rename destination (closed completed 2026-08-10)
- fs.watch on macOS reports only one file per ~40ms window, so bulk changes are mostly lost oven-sh/bun#39070 — macOS ~40 ms window drops bulk changes (closed completed 2026-08-15)
fs.watchwithrecursive: true: inconsistent filename (basename vs relative path) oven-sh/bun#29677 — recursive filename basename vs relative path (closed completed 2026-07-24)- fs.watch skips save events oven-sh/bun#7919 —
fs.watchskips save events (closed completed) - [Node Incompatibility] fs.watch on directory doesn't report change events for files created after watch oven-sh/bun#23992 — directory watch missed files created after watch (historical)
- fs.watch recursive on Linux: no event for a change behind a symlink entry that points outside the tree (regression in 1.3.14) oven-sh/bun#43065 — open — no event behind a symlink pointing outside the tree (1.3.14 regression)
- fs.watch on Linux: an event about the watched directory itself differs from Node (event type, filename for a trailing slash, recursive root) oven-sh/bun#43066 — open — watched-directory-itself event differs from Node; recursive root gives
change+undefinedfilename - fs.watch goes to infinite loop with big files oven-sh/bun#6955 — open — infinite loop with big files (2023)
watchdoesn't trigger on file writes on macOS oven-sh/bun#14568 — open — no trigger on file writes on macOS (2024, pre-rewrite)- Windows watcher: a ReadDirectoryChangesW buffer overflow drops the changes and nothing reloads oven-sh/bun#42936 — open — Windows
ReadDirectoryChangesWoverflow drops changes; notes the inotifyIN_Q_OVERFLOWgap - Windows watcher: treat every watched path as changed after a ReadDirectoryChangesW overflow oven-sh/bun#42951 — treat every watched path as changed after a Windows overflow
- fs.watch: report a watched symlink under its own name on Linux oven-sh/bun#43064 — open — report a watched symlink under its own name on Linux
- fs.watch: report a directory's attribute change as rename on Linux oven-sh/bun#43083 — open — report a directory's attribute change as
renameon Linux - fs.watch: watch symlink entries of a recursive tree on Linux oven-sh/bun#43090 — open — watch symlink entries of a recursive tree on Linux (never descends)
- fs.watch: run nextTicks and microtasks between the events of one batch oven-sh/bun#43715 — open — run nextTicks and microtasks between the events of one batch
- fs.watch/fs.watchFile: remove unsafe from the watcher modules oven-sh/bun#40200 — remove
unsafefrom the watcher modules - --hot/--watch causes fs.watch to drop filechange events oven-sh/bun#4854 —
--hot/--watchdrops file-change events (historical) - https://manpages.debian.org/testing/fswatch/fswatch.7.en.html — kqueue needs one fd per watched entry; FSEvents does not (background for the macOS cost model)
Scratchpad artifacts
harness.js, edges.js, stress.js, recreate.js, rewatch.js, errs.js,
out-bun-burst.json, out-chokidar-burst.json, out-edges-bun.json,
out-edges-chokidar.json, out-stress-bun.json, out-stress-chokidar.json,
out-recreate-bun.json, and the matching *.log files, all under
/tmp/claude-1001/-home-talkasab-peruse/e3fafb89-3d10-4a60-b3c0-d1fe62ffe04a/scratchpad/.
Activity
Merge feat/native-fs-watch into dev (#37)
talkasab commentedon Oct 4, 2026
Landed on
devin 5fff4a0; will ship in the next release (see[Unreleased]in CHANGELOG.md).