How Fallow Analyzes TypeScript in Under a Second
How Fallow gets dead-code and dependency analysis done fast by skipping the TypeScript compiler, flattening graph storage, and doing the work in Rust.
Fallow is a codebase analyzer written in Rust for TypeScript and JavaScript projects, created by Bart Waardenburg. I contribute to it, and my pull requests are where most of this understanding came from. It finds unused files, dead exports, unlisted dependencies, code duplication, circular dependencies, and complexity hotspots. It's a Rust alternative to Knip.
By the project's own benchmarks it runs 6 to 46 times faster than Knip on real repositories, with 4 to 11 times less memory. These are the internals behind those numbers.
No TypeScript Compiler
The biggest performance decision was skipping the TypeScript compiler. Knip runs through tsc to build a type-checked AST. This is thorough but expensive: you pay for type checking, declaration merging, and module resolution that a dead-code detector doesn't need.
Fallow uses Oxc, a Rust-native parser for JavaScript and TypeScript. Oxc parses syntax into an AST and provides scope-aware binding analysis through oxc_semantic, but it doesn't do type checking. For dead code detection, you need to know "what does this file export?" and "what does this file import?" Both are syntactic questions.
The cost is that Fallow can't detect unused type-narrowing exports or exports that are only used via type inference. These are rare enough that the 6-46x speedup is worth it, and the project's creator made that call deliberately.
Cache-Friendly Edge Storage
The module graph is the core data structure. Each file is a ModuleNode, and edges represent imports between files. The naive approach is storing edges as Vec<Edge> per node (adjacency list). This scatters edge data across the heap, killing cache performance during traversal.
Fallow stores all edges in a single contiguous Vec<Edge>, with each module node holding a Range<usize> into that array:
pub struct ModuleNode {
pub file_id: FileId,
pub path: PathBuf,
pub edge_range: Range<usize>, // range into flat edges array
pub exports: Vec<ExportSymbol>,
pub re_exports: Vec<ReExportEdge>,
pub is_entry_point: bool,
pub is_reachable: bool,
// ...
}When you traverse a module's imports, you're iterating over a contiguous slice of memory, which the CPU prefetcher loves. It's the same pattern game engines use for entity-component systems: structure of arrays instead of array of structures.
The project also enforces compile-time size assertions to prevent accidental bloat:
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ModuleNode>() == 104);
const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);If someone adds a field that bumps the size, the build fails, so memory regressions get caught before they show up as slowdowns.
Suffix Array Clone Detection
Code duplication detection usually involves pairwise comparison of every pair of code blocks for similarity. That's O(n^2) and falls apart on large codebases.
Fallow uses suffix arrays instead:
- Tokenize each source file into a sequence of abstract tokens (identifiers normalized, literals replaced with type markers)
- Concatenate all token sequences with sentinel separators between files
- Build a suffix array over the concatenated sequence using O(N log N) prefix-doubling with radix sort
- Compute the LCP array (Longest Common Prefix) to find maximal repeated subsequences
- Extract clone families from LCP runs that span different files
This finds every duplicated block across the codebase in a single pass, with no pairwise comparison. Building the suffix array is the expensive step, at O(N log N) where N is the total token count, compared with O(files^2) for pairwise matching.
On Next.js with 20,000 files, duplication detection completes in about 3 seconds. jscpd (the standard JavaScript duplicate detector) takes 24 seconds on the same codebase.
Rayon Parallelism with Incremental Caching
Parsing is embarrassingly parallel, since each file is independent. Fallow uses Rayon to parse files across all available CPU cores. On a laptop with a handful of cores, that alone cuts parsing time several-fold.
But cold parsing is still the bottleneck for large projects. Fallow's caching layer stores parsed results keyed by file content hash. On subsequent runs, only modified files get re-parsed. This turns a 1.5-second cold analysis into a ~200ms warm analysis, because most files don't change between runs.
Because the cache key is a content hash, renaming a file without changing its content doesn't invalidate the cache, and two files with identical content share a cache entry.
84 Framework Plugins, Zero Config
Fallow ships with 84 plugins that detect framework-specific patterns (Next.js page routes, Storybook stories, Vitest test files, Tailwind configs, etc.) and mark the relevant files as entry points. Without this, a dead-code detector would flag your pages/index.tsx as unused because nothing explicitly imports it.
The plugins are auto-detected from package.json dependencies, with no configuration. If you have next in your dependencies, Fallow knows that files matching app/**/page.tsx are entry points, which is how it gets away with zero config on real projects.
Benchmarks
Benchmarked on Apple M5, 32GB RAM, median of 5 runs:
| Project | Files | Fallow | Knip v5 | Speedup | Fallow RSS | Knip RSS |
|---|---|---|---|---|---|---|
| zod | 174 | 19ms | 639ms | 34x | 21 MB | 250 MB |
| fastify | 286 | 24ms | 1.13s | 46x | 27 MB | 289 MB |
| vue/core | 522 | 63ms | 702ms | 11x | 34 MB | 271 MB |
| svelte | 3,337 | 325ms | 1.93s | 6x | 67 MB | 460 MB |
| next.js | 20,416 | 1.48s | (crashes) | -- | 194 MB | -- |
Knip v5 can't analyze Next.js at all (exits with errors). Fallow handles it in under 2 seconds using less memory than Knip uses on a project 1/10th the size.
The speed comes from all of these decisions together: skipping the type checker, keeping data structures cache-friendly, finding duplication with suffix arrays, and parsing in parallel with an incremental cache. Each layer of the pipeline picks the faster option. Contributing to a codebase with this much performance engineering has been one of the best ways I've found to learn fast Rust.
End. Thanks for reading.