walkdir
walkdir is a zero-dependency Node.js directory walker with three interfaces: an asynchronous EventEmitter, a promise-returning helper, and a synchronous function. It emits separate events for files, directories, links, empty directories, sockets, FIFOs, and device nodes, and it can filter entries, stop, pause, ignore subtrees, follow symbolic links, or collect paths and fs.Stats objects. It predates modern async iterators and glob libraries, but remains useful when event-level control matters.
walkdir still offers an unusually flexible emitter for legacy CommonJS scripts, especially dynamic ignore and node-type events. For new code, fdir or readdirp offers a more current async shape, types, and maintenance story.
Use it if
- Existing CommonJS code already depends on walkdir's file, directory, link, fail, and empty events
- You need to ignore a directory dynamically from an event callback or pause and resume event delivery
- A one-off Node script needs both sync and async tree walking with no runtime dependencies
- You need to inject an fs-compatible implementation and can satisfy the package's stat, lstat, readdir, and readlink contract
- You need active releases or a stable 1.x API: the latest version is 0.4.1 from July 2019 and the repository's last push was in December 2022
- You need built-in TypeScript declarations, ESM exports, async iteration, or AbortSignal cancellation: the package is CommonJS and ships none of those interfaces
- You need glob syntax, gitignore rules, or deterministic ordering: filtering receives raw directory entries, and asynchronous fs completion controls event and result order
- Unreadable descendants are normal in your promise workflow: walk.async rejects on every fail event, while the emitter API is the one that can report a failed child and continue
- You will use asynchronous filters that can reject: the source handles resolved filter promises but attaches no rejection handler, which can leave its internal job count pending and surface an unhandled rejection
Setup reality
npm install walkdir is the only setup step. There are no dependencies, native builds, peers, credentials, or config files, and Node 6 or newer is declared. The runtime is CommonJS, so use require('walkdir'); TypeScript users need their own declaration or the separately maintained @types package if suitable. Choose the interface carefully. walk(path) starts immediately and returns an EventEmitter, so attach listeners in the same tick. The initial directory emits targetdirectory, not the ordinary path and directory events. Child read or stat failures emit fail; only inability to read the requested target also becomes error. walk.async converts any fail into a rejected promise, so a single permission-denied descendant rejects the whole call even though the underlying walk can still have in-flight work. By default the package uses lstat, emits link entries, and does not follow them. follow_symlinks can create cycles; inode tracking is enabled, but the README warns that inode identity is unreliable on some filesystems and hard-link situations. Combine max_depth with symlink following, and listen for the source's maxdepth event if truncation matters. no_recurse still reads the root once, producing one-level results. no_return prevents building a potentially huge list. pause only queues emitted events; it does not pause filesystem reads, and end suppresses further handling without cancelling in-flight fs operations. The filter callback must return an array or a resolving promise of filenames, not full paths.
Patterns
Visit every child path asynchronouslywalk-with-callback
const walk = require('walkdir');
walk('./content', function (pathname, stat) {
console.log(pathname, stat.isFile());
});The callback is attached to path events. The starting directory itself emits targetdirectory instead of path.
Handle files and directories separatelylisten-by-node-type
const emitter = walk('./content');
emitter.on('file', (pathname, stat, depth) => {
console.log('file', depth, pathname);
});
emitter.on('directory', (pathname, stat, depth) => {
console.log('directory', depth, pathname);
});Attach listeners immediately because the walk starts before walk() returns. Async event order follows filesystem completion, not sorted path order.
Collect paths with the promise APIawait-path-list
const paths = await walk.async('./content');
console.log(paths);Any descendant fail event rejects walk.async. The returned path order is not deterministic, so sort it when output order matters.
Collect a path-to-stats objectawait-path-stats
const entries = await walk.async('./content', {
return_object: true,
});
for (const [pathname, stat] of Object.entries(entries)) {
console.log(pathname, stat.size);
}The object retains an fs.Stats instance for every emitted path and can consume substantial memory on a large tree.
Return paths synchronouslywalk-synchronously
const paths = walk.sync('./fixtures');
for (const pathname of paths) {
console.log(pathname);
}Synchronous traversal blocks the event loop. Keep it to startup code, tests, and small command-line jobs.
Avoid retaining a huge result liststream-without-collecting
const emitter = walk('./large-tree', { no_return: true });
emitter.on('file', (pathname) => indexFile(pathname));
emitter.on('end', () => console.log('done'));no_return matters for sync and promise collection. The EventEmitter interface already lets you process entries incrementally.
Filter names before descendingfilter-directory-entries
const emitter = walk('./project', {
filter(directory, files) {
return files.filter((name) => name !== 'node_modules' && !name.startsWith('.'));
},
});Return entry names, not joined paths. If an async filter rejects, version 0.4.1 does not handle that rejection cleanly.
Skip a subtree after inspecting its pathignore-directory-dynamically
const path = require('path');
walk('./project', function (pathname, stat) {
if (stat.isDirectory() && path.basename(pathname) === '.git') {
this.ignore(pathname);
}
});The callback's this value is the emitter. Call ignore when the directory path event arrives, before its queued readdir is processed.
Cap traversal depthlimit-walk-depth
const emitter = walk('./project', { max_depth: 3 });
emitter.on('maxdepth', (pathname, stat, depth) => {
console.warn('not descending', depth, pathname);
});The source emits maxdepth when the cap is reached; it does not emit error despite wording in the options section of the README.
Continue after an unreadable childhandle-permission-failures
const emitter = walk('/srv/data');
emitter.on('fail', (pathname, err) => {
console.warn('skipped', pathname, err?.code);
});
emitter.on('error', (err) => {
console.error('target failed', err);
});fail is for descendant stat or read failures; error is reserved for failure of the requested starting path.
Follow links with a depth safety capfollow-symbolic-links
const emitter = walk('./tree', {
follow_symlinks: true,
max_depth: 20,
track_inodes: true,
});
emitter.on('link', (pathname) => console.log('link', pathname));Symlink loops are possible. Keep inode tracking enabled and set max_depth, especially on filesystems where inode identity is unreliable.
Stop accepting more resultscancel-active-walk
const emitter = walk('./tree');
let files = 0;
emitter.on('file', () => {
if (++files >= 100) emitter.end();
});end suppresses later processing but does not cancel fs calls already in flight. There is no AbortSignal integration.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fdir | npm | You want a fast maintained crawler with promise, callback, glob, and builder-style options |
| readdirp | npm | You want a maintained Node directory stream with filters and async-iterator consumption |
| fast-glob | npm | Your real requirement is matching glob patterns with ignore rules rather than observing filesystem node types |
| klaw | npm | You want a simple readable-object stream of paths and stats in older CommonJS code |