Why Your Build Is Slow: A Checklist for Faster Builds
A slow build is rarely slow for one reason. It is usually a stack of smaller costs: a compiler reading thousands of files it does not need, a type checker blocking the bundler, a machine with many cores running one job at a time, a scanner inspecting every file the build writes. The fix is rarely a new tool. It is a measurement, then removing the biggest cost, then another measurement.
This checklist covers the usual suspects in the order worth checking them.
Measure before you change anything
Start with a baseline. Run the build three to five times and write down every number, not just the best one. A single run tells you very little, because the first build after a reboot or a branch switch behaves differently from the fifth.
Then ask your tools where the time goes. Most of them can tell you directly.
TypeScript
The TypeScript compiler has two flags built for this, both documented in the TypeScript performance guide. --extendedDiagnostics prints counts and timings after a build: files, lines, symbols, types, memory used, and the time spent parsing, binding, checking and emitting. --generateTrace writes a detailed event trace to a folder.
npx tsc --extendedDiagnostics -p tsconfig.json
npx tsc -p tsconfig.json --generateTrace trace_output
Read the first output like this. If check time dominates, your types are expensive to compute, and the trace will show which files and types are responsible. The guide explains that you can open the trace at about://tracing in Chrome or Edge, or summarize it with the @typescript/analyze-trace package. If the file count is far higher than the number of files you wrote, the problem is what the compiler is pulling in, which is the next section.
Gradle
Gradle has two built-in views, both described in its profiling docs. ./gradlew --profile build writes an HTML report to build/reports/profile in the root project. ./gradlew build --scan produces a Build Scan, a persistent, shareable record of the build that is more detailed than the profile report. Gradle prints a URL where you can view the scan, so the data leaves your machine. Check your team's policy before scanning private code.
Vite
Run vite --profile, load the app, then press p and Enter in the terminal to record a .cpuprofile file. The Vite performance guide suggests opening that file in speedscope to find the bottlenecks.
Whatever the tool, this step should end with one sentence you can write down: "most of the time goes to X." Everything below is a guess until you have that sentence.
Check how much code the build actually reads
Build time scales with what the tool has to read, parse and analyze, and that is often far more than your own source.
- See why each file is included. The TypeScript guide recommends
tsc --explainFiles > explanations.txtto record why every file is part of the program, and--listFilesOnlyto list the files and exit without compiling. Look for whole directories you did not expect: test fixtures, generated code, another package's output folder. - Keep the project boundaries narrow. The same guide advises specifying only the input folders whose source you want compiled, and excluding
node_modulesand hidden folders so the compiler does not wander into them. - Stop loading every
@typespackage. By default, older TypeScript versions included every@typespackage found innode_modules, whether you imported it or not. TypeScript 7.0 adopts the TypeScript 6.0 default of an emptytypeslist, so on current versions you name the global type packages you need, such as["node", "jest"]. On an older version, settingtypesexplicitly gets you the same saving. - Watch for barrel files. The Vite performance guide explains that importing one function from an index file that re-exports a whole folder forces Vite to fetch and transform every file in that folder. Importing from the specific module avoids the extra work.
Take type checking off the critical path
Type checking is a whole-program analysis. Turning TypeScript into JavaScript is not: it can be done one file at a time, which is why transpile-only tools are so fast. Many setups now split the two jobs.
Vite's documentation states that it only transpiles .ts files and does not type check them, and recommends running tsc --noEmit in addition to the production build. For webpack, the TypeScript performance guide describes the same split: transpileOnly in ts-loader for emitting, plus fork-ts-checker-webpack-plugin to check types in a separate process without blocking emit.
Two rules keep the split honest:
- The type check still has to run, and it still has to fail the build. A bundle that compiled is not evidence that the types are correct. Run
tsc --noEmitas its own step and treat a nonzero exit code as a failure. - Run it alongside the bundle, not after it. If your script runs type checking and bundling one after the other, you separated them without saving any wall-clock time.
Also look at the compiler version. TypeScript 7.0, announced on July 8, 2026, is a native port of the compiler, and the TypeScript team reports speedups typically between 8x and 12x on full builds. The announcement came with a catch: TypeScript 7 did not yet expose a stable programmatic API, so tools that embed TypeScript for Vue, Svelte, Astro, MDX or Angular templates could still only rely on TypeScript 6.0. Check where your stack stands before you upgrade.
Put your other cores to work
Parallelism is the cheapest speedup when the work is independent, and it does nothing when it is not.
- Check utilization first. Watch your system's activity monitor during a build. If one core is busy and the rest are idle, something in the build is serial.
- Use what the tool already offers. TypeScript 7 parses, type checks and emits in parallel on its own. It also adds experimental
--checkersand--buildersflags to tune type checking and project reference builds, plus--singleThreadedto turn parallelism off when you are debugging or running with limited resources. - Break up the dependency graph. A build tool can only run in parallel what does not depend on something else. One giant module that everything imports makes everything wait for it. Splitting a monolith into packages with clear boundaries gives the scheduler independent work to hand out.
- Do not overdo workers. Every extra process has a startup cost, and every message between processes has to be serialized. On a small project, more workers can make a build slower. Memory is the other cost: the TypeScript 7 announcement notes that raising
--checkersabove its default of 4 can speed up large codebases but typically uses more memory. Measure after each change.
Look at the disk and the file watchers
Builds read thousands of small files and write thousands more, so anything that slows file access slows the build.
Keep the repository on a local disk. Network storage adds latency to every file operation. Gradle's file system watching docs list Samba and NFS as unsupported for watching. They also list Microsoft Dev Drives (ReFS) as unsupported, which matters if you moved a repository to a Dev Drive for speed.
Mind real-time scanning. Security software that inspects every file the build writes adds a cost to each write. Work with whoever owns your machine's security policy rather than switching protection off yourself.
Check Linux watch limits. Gradle's docs show how to read the current inotify limit and suggest raising it to 524,288 watches when a large project runs out. For a single build,
--no-watch-fsturns Gradle's watching off.cat /proc/sys/fs/inotify/max_user_watchesKeep output folders out of watchers. A watcher that tracks
dist,buildornode_modulesdoes extra work every time the build writes, and can trigger rebuilds from the build's own output.
Compare cold and warm builds
A cold build starts from nothing: a fresh clone, a new machine, a cleared cache, a stopped background process. A warm build runs right after another one, with long-lived processes still running and the operating system's file cache full. Measure both, because they fail for different reasons.
- Account for background processes. Gradle's profiling docs note that even when a build has a long startup time, later runs usually see a dramatic drop in it. The startup phase is where Gradle starts its background daemon if one is not already running, so a benchmark taken right after stopping the daemon is mostly measuring startup.
- Reproduce cold on purpose. Clear the tool's cache directory, stop its daemon and time the build again. That is much closer to what a teammate sees after cloning the repository.
- Read the gap. If warm builds are fast and cold builds are slow, the compile work is fine and the problem is reusing results across runs and machines. If warm builds are slow too, the work itself is too big, so go back to the profile.
The checklist
- Record a baseline of three to five runs, cold and warm.
- Profile with the tool's own reporting:
tsc --extendedDiagnostics,gradle --profileor--scan,vite --profile. - Count the files the build reads, and cut the folders and type packages it does not need.
- Move type checking into its own step that runs alongside the bundle and still fails the build.
- Confirm the build uses more than one core, then split whatever forces it to run serially.
- Keep the repository on a local disk and keep output folders out of watchers.
- Re-measure after every single change.
The last item is the whole method. Keep a change only if it moved the number; if the measurement did not move, revert it, because an unmeasured tweak is one more thing to maintain.