Skip to content
rusthacks.dev

Cargo Build Times and Seven Changes That Actually Help

Seven changes that cut Cargo build time: check instead of build, a faster linker, dependency debug settings, incremental, workspace splits, sccache and feature trimming.

6 min read
Summarize in AI
Flat illustration for the post “Cargo Build Times and Seven Changes That Actually Help”, composed around cargo.

"Rust compiles slowly" is true on average and false about your project's next rebuild. Most of what makes a build feel slow is rebuilding things that did not change, or doing work you did not need. This post measures seven changes on a real program, ranks them by what they bought, and says plainly which ones did almost nothing here. Where I could not measure on this machine, I say so rather than quote someone else's figures.

The measurements are from Cargo 1.99 with edition 2024 on a 10-core Apple M1 Max, on a program that depends on serde_json 1 and regex 1 (11 crates in total). A clean dev build is 2.7 seconds, so every saving below is small in absolute terms; the ratios are what carry over to a larger project.

Start by finding where the time goes

Do not guess. Cargo ships a profiler for its own scheduling:

Shell
cargo build --timings
Text
      Timing report saved to /work/pf/target/cargo-timings/cargo-timing-20261110T162121748Z-bae1856afaa15432.html

Open the HTML file. It draws each crate as a bar on a timeline and shows how many were compiling at once. Two patterns decide what to fix. A long bar on its own with nothing beside it is a serial bottleneck: a build script, a proc-macro crate such as syn or one huge crate in your own tree. Many short bars stacked in parallel mean you are already using your cores, and the remaining lever is doing less. Everything below is a way to remove bars or shorten the long ones.

1. Keep the cache warm

The biggest saving is not rebuilding. Once dependencies are built, an edit to your own crate costs only your crate:

Text
clean build (cargo clean first)         2.75 s
touch src/main.rs and build             0.20 s
change RUSTFLAGS and build              2.65 s
build again with the same new flags     0.19 s
return to the original flags            0.24 s

That is 13 times faster after an edit. The surprise is the middle row: changing RUSTFLAGS invalidated the whole cache, dependencies included, because the flags are part of every crate's fingerprint. Cargo keeps both versions, so returning to the old flags was free, but a workflow that alternates flags between two terminals, or a tool that sets RUSTFLAGS for coverage and clippy differently, quietly rebuilds the world every switch. Fix it by giving each configuration its own target directory (CARGO_TARGET_DIR=target/cov) so they stop invalidating each other. The same trap applies to rust-analyzer: point it at a separate directory with rust-analyzer.cargo.targetDir in your editor settings.

2. Use cargo check for the inner loop

Most edits do not need a binary. After touching one file, cargo check took 0.07 s against 0.20 s for a build, and a clean check was 1.99 s against 2.69 s. The saving grows with project size because check skips code generation and linking. See check versus build for what it misses.

3. Give the compiler cores

Cargo runs as many jobs in parallel as there are CPUs, which is usually right. The effect is easy to see by limiting it:

Text
cargo build -j 1     8.49 s
cargo build -j 2     4.44 s
cargo build          2.69 s   (10 jobs)

The practical point is for CI and containers. A runner with two vCPUs spends twice the time of a laptop with ten on the same dependency graph, and a container with a CPU quota may report the host's core count and oversubscribe. If you suspect the detection is wrong, pin it with cargo build -j 4 or build.jobs = 4 in .cargo/config.toml and compare the timings report. More jobs than cores does not help.

4. Drop dependencies and features you do not use

Every crate you depend on is a bar on the timeline, and every default feature can pull in more. Disabling the defaults of regex and keeping only what the program needs cut the clean build from 2.69 s to 2.51 s, 7 percent, on an 11-crate tree. The method is two commands:

Shell
cargo tree --depth 1
cargo tree -e features -i regex
Cargo.tomlTOML
[dependencies]
regex = { version = "1", default-features = false, features = ["std", "perf"] }
serde_json = "1"

The first command lists what you depend on directly; the second shows who turned on which feature, so you know whether the cost is yours or inherited. The savings are far bigger when the offender is a heavyweight such as a full async runtime or a TLS stack pulled in for one request. A project that compiles three HTTP clients because three dependencies each chose a different one is the common case; cargo tree -d lists crates present twice.

5. Trim debug information

The dev profile writes full debug info. Dropping it to line numbers only, which still gives usable backtraces, is one line:

Cargo.tomlTOML
[profile.dev]
debug = "line-tables-only"

On this program the clean build went from 2.69 s to 2.46 s (9 percent), and debug = 0 gave 2.60 s. The saving is real but small here; it grows with the number of crates and with link time, where debug info is most of the work. You lose stepping through code in a debugger, so keep full info if you use one.

For an incremental build of a large binary, the linker is often the single longest step, because it runs alone after all the compiling has finished. Faster linkers (lld, mold) replace the system one, and since Rust 1.90 the stable toolchain uses lld by default on x86_64-unknown-linux-gnu. I did not measure linkers here: this program links in a fraction of a second on this machine, so there is nothing to see. Faster linkers shows how to test the difference on a Linux build host where the link step actually takes time.

7. Cache across CI runs

A CI job that starts with an empty target/ pays the clean-build price every time: 2.7 s here, and several minutes for a real application. Caching the target directory and the registry between runs turns it into the 0.2 s case. Plain directory caching works but grows without bound; a compiler cache such as sccache stores compiled units by content hash and survives across branches. Cache keys should include Cargo.lock so a dependency update gets a fresh cache.

Things that did not help

Turning incremental compilation off made a clean build no faster (2.63 s against 2.69 s), as expected, since incremental only helps rebuilds. A higher codegen-units in dev does nothing because dev is already at 256. And cargo build --release for development is never the answer: it was 4.2 s clean here and its incremental rebuilds were slower than dev's (0.36 s against 0.20 s).

When build time is not your problem

If the clean build is 3 seconds and the rebuild 0.2, the time is going somewhere else: tests, a slow editor check or a pipeline step. Profile the pipeline before the compiler. And for small projects, none of this is worth an afternoon. Start with --timings, pick the one or two changes the report points at, and re-measure. The full cargo option list is in the timings reference.

Also inTooling
Share

Keep reading

Flat illustration for the post “Incident Response for Rust Services a Short Runbook”, composed around deploy.
DevOps

Incident Response for Rust Services a Short Runbook

A one-page runbook for when a Rust service misbehaves: stabilize first, check recent changes, read the four golden signals, find the failing dependency, and use the Rust-specific evidence: panics, memory, executor stalls.

4 min read
Flat illustration for the post “Debugging Async Backtraces and Task Dumps in Tokio”, composed around async.
Tooling

Debugging Async Backtraces and Task Dumps in Tokio

A hung async service has no useful thread backtrace. Tokio task dumps capture the await stack of every task. Enable tokio_unstable and taskdump, trigger a dump from a signal or endpoint, and read where each task is parked.

4 min read

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.