Node fs.glob: What Breaks When You Drop fast-glob

Node has a built-in glob: fs.glob, fs.globSync and glob from node:fs/promises, stable since Node 24.0.0 and 22.17.0. It finds files by pattern, but it does not behave like fast-glob. It returns directories, does not follow symlinked directories, skips dot folders under **, ignores ! negation and matches case-insensitively on macOS and Windows.

We ran every example below on Node 24.12.0 (macOS 26.6.2, Apple silicon), alongside fast-glob 3.3.3 and tinyglobby 0.2.17, against a small fixture tree we built for the purpose. Version ranges that go beyond 24.12.0 come from the Node docs and were not tested.

What does the node fs glob API look like?

Three functions, one shape. From the Node 24 fs docs:

import { glob } from 'node:fs/promises';

for await (const entry of glob('**/*.js')) {
  console.log(entry);
}

fsPromises.glob returns an async iterator, fs.globSync returns an array, and fs.glob takes a callback. The pattern can be a string or an array of strings. In 24.12.0 the options are cwd (a string, or a URL since 24.1.0), exclude (a function, or an array of glob patterns since 23.7.0 and 22.14.0) and withFileTypes, which yields Dirent objects in place of strings.

That is the whole list. There is no dot, no onlyFiles, no absolute, no ignore and no caseSensitiveMatch. The changelog in the docs dates the move to stable to 24.0.0 and 22.17.0 (PR #57513). The Node 26 docs add followSymlinks (26.1.0) and maxDepth (26.9.0), which we have not run.

Why does node glob return directories?

Because it matches names, not file types. Our fixture had a directory called src/dir.js. Here is what **/*.js returned:

Toolsrc/dir.js in results?src/** result starts with
fs.globSyncyessrc, then src/a.js
fast-globnosrc/a.js
tinyglobbynosrc/a.js

fast-glob documents onlyFiles as defaulting to true in its README. Node has no equivalent switch. The bigger effect is on patterns ending in **: in Node, src/** hands you src itself and every subdirectory as well as the files. Code that passes each result to readFile then fails with EISDIR.

The fix is withFileTypes: true and a filter on entry.isFile(), which the helper further down does.

Not under **. The 24.12.0 implementation skips any entry whose name starts with . while expanding a globstar, before it tests the rest of the pattern. So in our run **/.*.js returned nothing, even with .eslintrc.js sitting in the root, and **/.cache/*.js returned nothing with src/.cache/d.js on disk. fast-glob and tinyglobby found both, because a dot written into the pattern opts that segment in.

Literal paths do work in Node. .github/*.js, .*.js and src/.cache/*.js all matched. If a build script needs .github/workflows or .storybook, spell the folder out.

Symlinks go the other way. Our fixture had src/link pointing at a sibling directory. fast-glob and tinyglobby both returned src/link/deep/f.js. Node 24.12.0 did not descend into it, and with withFileTypes the link came back as a Dirent where isFile() and isDirectory() are both false. fast-glob documents followSymbolicLinks defaulting to true. In a pnpm workspace or a monorepo that links packages, that difference changes which files a script sees.

What happens to negation and exclude?

! patterns do nothing. Node builds its matchers with minimatch’s nonegate option, so ['src/*.js', '!src/a.js'] returned src/a.js anyway, with no error and no warning. The docs do say negation is unsupported in exclude; the pattern array is where it bites, because fast-glob’s README lists negative patterns as supported there.

Use exclude in its place. Our checks showed it prunes the walk rather than filtering afterwards: an exclude function that returned true for node_modules was never called for anything inside it. On a generated tree of 40,000 empty files, half under node_modules, the median of seven **/*.js runs was:

CallMedian (ms)
fs.globSync, no exclude79 to 86
fs.globSync, exclude: ['node_modules/**']45
fs.globSync, exclude function on the name37
fast-glob, ignore: ['**/node_modules/**']15 to 16
tinyglobby, same ignore17 to 19

The ranges cover two runs. That is one machine, one synthetic tree and warm caches, so treat it as a direction rather than a benchmark. On this tree, fast-glob and tinyglobby were between two and three times quicker than the built-in.

Write the exclude function against a Dirent, not a string. In string mode it receives a bare name for some entries and a relative path for others: we saw both lib and src/lib for the same directory. A check like p.startsWith('src/lib') happened to work, because one of the two calls carried the path. A check on the full path alone is fragile. With withFileTypes: true the function gets a Dirent, and checking entry.name is predictable.

Why do results differ between a Mac and CI?

The 24.12.0 source sets minimatch’s nocase to true when the platform is macOS or Windows, with nocaseMagicOnly, so only the wildcard parts of a pattern ignore case. On our Mac, src/*.JS matched src/a.js. fast-glob and tinyglobby returned nothing for the same pattern. On Linux the same Node code sets nocase to false, so src/*.JS should match nothing there. We read that from the source and did not run it on Linux.

A literal path behaves differently again. SRC/a.js on macOS returned SRC/a.js, spelt the way the pattern spelt it, because the case-insensitive file system answered the lookup. Anything that compares those strings later gets the pattern’s casing, not the disk’s.

Ordering is not guaranteed. Node returned src entries before node_modules and dist; fast-glob started with dist. Sort if anything downstream depends on order. Duplicates across patterns were removed: ['src/*.js', 'src/a.*'] gave src/a.js once.

A fast-glob-shaped wrapper for node fs glob

This keeps the built-in and restores the defaults most scripts assume: files only, named directories pruned, POSIX separators, sorted output.

// find-files.mjs: run on Node 24.12.0 only
import { globSync } from 'node:fs';
import path from 'node:path';

/**
 * Files-only glob on node:fs, with named directories pruned.
 * Returns sorted, POSIX-style paths relative to cwd.
 */
export function findFiles(patterns, options = {}) {
  const { cwd = process.cwd(), skipDirs = ['node_modules'] } = options;
  const skip = new Set(skipDirs);
  return globSync(patterns, {
    cwd,
    withFileTypes: true,
    exclude: (entry) => entry.isDirectory() && skip.has(entry.name),
  })
    .filter((entry) => entry.isFile())
    .map((entry) => path.relative(cwd, path.join(entry.parentPath, entry.name)))
    .map((file) => file.split(path.sep).join('/'))
    .sort();
}
import { findFiles } from './find-files.mjs';

console.log(findFiles(['**/*.js', 'src/*.ts']));

Run from our fixture root, that printed dist/out.js, linked-target/deep/f.js, src/a.js, src/b.ts and src/lib/c.js. src/dir.js and node_modules were gone. On the 40,000-file tree it returned the same 10,000 paths as fast-glob with ignore: ['**/node_modules/**'], compared after sorting, in about 62 ms a call.

It does not fix everything. Symlinked directories are still not followed. A symlink to a file is dropped too: in a separate check, fs.globSync('src/*.js') returned src/alias.js, a link to a real file, and the wrapper filtered it out because its Dirent reports isFile() as false. Dot folders under ** are still skipped. The separator line only matters on Windows, where path.sep is a backslash; we did not run it there.

The function is synchronous because the build scripts and CLIs we would use it in are short-lived. For a long-running server, use the async iterator from node:fs/promises with the same options.

Should you replace fast-glob with node fs glob?

For a build script, a test file finder or a small CLI where you control the patterns, yes. On Node 24, or 22.17 and later according to the docs, it removes a dependency, and the wrapper above deals with directories in results, pruning, separators and ordering. It pairs well with our note on parseArgs if you are trimming a CLI down to built-ins, and with node --watch for the dev loop.

Keep fast-glob or tinyglobby where patterns come from users or config files written for those libraries, where negation is part of the format, or where you need dotfiles under ** or symlinked packages followed. Keep them too if globbing is on a hot path: on our tree they were two to three times faster. Revisit on Node 26, where followSymlinks closes one of the gaps.

We build and maintain Node.js tooling and services, build scripts included. If a dependency audit has turned up more packages than you would like, our Node.js development team can help work out which ones the runtime now covers.

Need this built properly?

Get in touch