8 Rust Compilation Techniques That Slashed My Build Times From Minutes to Seconds

Slash Rust compile times with 8 proven techniques. From workspaces to sccache, learn how to speed up your builds and get faster feedback. Start optimizing now.

8 Rust Compilation Techniques That Slashed My Build Times From Minutes to Seconds

When I first started using Rust, one thing made me want to throw my keyboard across the room – the compilation times. I had heard that the language was safe, fast, and loved by everyone. No one warned me that a simple change would take a coffee break to compile. I remember waiting over a minute just to see if my loop bound was correct. That kind of wait breaks your rhythm. You stop thinking about the code and start worrying about the clock.

Over the years I have picked up eight techniques that changed everything. They did not make Rust slow for me anymore. They turned the compiler into a fast friend instead of a roadblock. I want to share each one with you, step by step, so you never have to stare at the terminal again, waiting for that green light.

Let me start with the most common mistake. People write one giant crate for their entire project. They have main.rs that imports mod utils, mod api, mod database, and everything lives inside one big directory. The problem is simple: the Rust compiler treats every file as part of a single unit. When you change one line in database.rs, the compiler has to reanalyse the whole crate. It cannot tell that api.rs is untouched. It recompiles everything from scratch.

I learned this the hard way when I had a three‑thousand‑line crate for a web server. Every time I tweaked a constant, I had to wait thirty seconds. A friend suggested splitting it into a workspace. A workspace is just a collection of crates that share a common Cargo.lock file. You put each logical section into its own crate – core, web, models, cli. When you change web, only web and crates that depend on it recompile. The rest stays cached.

Setting up a workspace is easy. Create a root Cargo.toml that lists the members. Here is what it looks like:

[workspace]
members = [
    "core",
    "web",
    "models",
    "cli",
]

Now each member directory has its own Cargo.toml. Dependencies between them are normal path dependencies. When I did this, my compile time for a single change dropped from thirty seconds to eight seconds. That felt like a miracle.

The next thing I recommend to everyone is using cargo check during development. I used to type cargo build every time, even though I only wanted to see if my code type‑checked and borrowed correctly. cargo build does everything – parsing, type checking, borrow checking, code generation, and linking. The last two steps take the most time because LLVM has to produce executable machine code.

cargo check stops after the borrow checker. It skips LLVM entirely. For most of my day, I do not need an executable. I just need to know if my code is correct. Running cargo check is often two to five times faster. I made it my default. I only run cargo build before pushing to the repository or running tests.

Here is how I use it:

cargo check          # fast feedback
cargo test           # builds with test harness, slower
cargo build --release # for deployment only

I have a habit of keeping a terminal open with cargo watch -x check, so every time I save a file, it runs check automatically. That way I get feedback in under two seconds for small changes.

Now let us talk about dependencies. Rust makes it easy to add a crate for everything. That is both a blessing and a curse. Each time you add a dependency, you are adding hundreds of lines of code that your compiler needs to parse, analyse, and optimize. Some crates pull in huge dependency trees without you noticing.

I once added a crate called serde with its default features, which include support for YAML, JSON, and other formats. I only needed JSON. The default features compiled a lot of extra code I never used. I changed my Cargo.toml to disable default features and only enable derive and std. The compile time for my project dropped by forty percent.

You can see what you actually pull in with cargo tree. This command shows the dependency graph. Look for duplicates or heavy crates that you do not really need. For example, if you see both rand 0.7 and rand 0.8, you have two versions compiled. You can fix that by updating your dependencies to use the same version.

Here is how I audit mine:

cargo tree --duplicates
cargo tree --invert serde

The second command shows why serde is included – maybe an indirect dependency you did not know about. Removing unnecessary dependencies is one of the fastest ways to shrink compile times.

I also learned about incremental compilation and parallel code generation early on. Rust already turns on incremental compilation for debug builds. That means your second compile is much faster because it reuses intermediate results. But sometimes people disable it by mistake in their profile settings.

Check your Cargo.toml:

[profile.dev]
incremental = true

That is the default, but if you changed it, put it back. For release profiles, you can also enable incremental compilation, but it is turned off by default because it can produce slightly slower code. If you are doing a lot of release builds for development, you can turn it on.

Parallel code generation is another trick. By default Rust uses one LLVM code generation unit per crate, but you can split the work across multiple units. This makes the final optimisation step go faster, at the cost of potentially less optimised code. For debug builds, that trade‑off is fine. For release builds, you can still use it if you want faster builds and do not mind a small performance penalty.

Set codegen-units in your profile:

[profile.dev]
codegen-units = 8   # match your number of cores

[profile.release]
codegen-units = 16  # more for release, adjust

Do not go crazy high – there is a point of diminishing returns because splitting into too many units produces overhead. I usually set it to the same as my CPU core count.

A more advanced tip is precompiling dependencies using sccache or using cargo vendor. When you work in a team or on a CI server, every machine compiles the same dependencies from source. That is wasteful. You can cache the compiled artifacts and share them.

sccache acts as a proxy for the Rust compiler. It stores previously compiled objects in a local or shared cache. The first build is a bit slower because of hashing, but subsequent builds, especially on different machines, can reuse artifacts. I set it up once and forgot about it. It saved hours on my CI pipeline.

cargo vendor is simpler – it downloads all dependency sources into a local directory. You can then commit that directory and have a deterministic offline build. This does not speed up the first build, but it avoids network time and makes sure everyone uses the same code.

Here is the setup:

cargo vendor --third-party-dir ../vendor
# Then edit .cargo/config.toml
[source.crates-io]
replace-with = "vendored-sources"

[source.vendored-sources]
directory = "../vendor"

Now your compiles will use the local copies.

One of the most frustrating causes of slow compiles in my own code was heavy use of generics. Generics are great for code reuse, but the compiler has to monomorphise every generic function for every concrete type you use it with. If you have a function that takes a generic parameter T and you call it with ten different types, the compiler generates ten copies of the function. Each copy needs its own machine code. That adds up fast.

I once had a generic process function inside my main app crate. It was used with different enums and structs all over the place. Every change to that function triggered recompilation of all its monomorphisations. The fix was to move the generic function to a separate, rarely‑changed crate, or to replace it with a concrete type that used an enum internally.

If you cannot avoid generics, at least put them in a stable crate that does not change often. That way the monomorphised code stays cached.

Here is a simple example:

// Instead of writing this in your app crate:
fn process<T: Processor>(item: T) { /* ... */ }

// Consider using an enum if the types are limited:
enum ProcessorType {
    Alpha,
    Beta,
}
fn process(kind: ProcessorType) { /* ... */ }

It is less flexible, but for my use case it cut compile time by half.

Another missed opportunity is link‑time optimization. LTO works by analysing the whole program during linking, which can produce faster executables but also makes linking take much longer. Many people enable lto = true in their release profile and then wonder why the final build is slow. You rarely need full LTO during development. I only enable it for the final release build, and even then I prefer thin LTO.

thin LTO gives you most of the speed benefit without the huge compile time penalty of full LTO. It strikes a good balance. My release profile looks like this:

[profile.release]
lto = "thin"
codegen-units = 1   # for maximum optimisation, but slow

If you set codegen-units = 1, LTO can work on the whole crate at once. That produces the best runtime performance but the slowest compile. I only do that for the actual release binary I ship to users. For internal benchmarking, I use codegen-units = 16 with lto = false.

Finally, the most important technique I never used in the beginning: profiling my own build. Rust ships with a built‑in profiler on nightly. Running cargo build --timings produces an HTML file that shows you exactly where your time is spent. You can see which crates take the longest to compile, which ones block parallel execution, and where you might split things.

I ran that command on my project and discovered that a single crate – the one containing all my serialisation code – accounted for sixty percent of the build time. I split that crate into two and suddenly the critical path had two parallel branches. My build time dropped by another twenty percent.

The command is:

cargo +nightly build --timings

Then open the generated target/cargo-timings/cargo-timing.html file in your browser. It has a Gantt chart showing when each crate started and finished. Look for long bars that are lonely – those are bottlenecks.

I once saw that my web crate was waiting for database to finish, even though they did not depend on each other. They shared a dependency that was compiled sequentially. I reordered the workspace members so that independent crates could compile in parallel. That helped a little, but the real win was removing the shared dependency and inlining a small utility.

Each of these techniques took me about an hour to understand and apply. The total time investment was less than a day. The payoff has been huge: my development loop now takes seconds instead of minutes. I never dread running cargo check anymore. In fact, I do it so often it has become a reflex.

I recommend you start with the simplest changes. Use cargo check whenever you can. Split your project into a workspace with two or three crates. Run cargo tree once and remove any unnecessary dependencies. Then, if you still have time, set up incremental compilation and parallel codegen. If you are using a team or CI, look into sccache. Avoid generics in hot spots. Use thin LTO for releases. Finally, run the timing profiler and see where your own project hurts.

You do not need to implement all eight at once. Even one of these changes can turn a frustrating wait into a quick feedback loop. Try it today. Your future self will thank you.


// Keep Reading

Similar Articles

Rust's Atomic Power: Write Fearless, Lightning-Fast Concurrent Code
Rust

Rust's Atomic Power: Write Fearless, Lightning-Fast Concurrent Code

Rust's atomics enable safe, efficient concurrency without locks. They offer thread-safe operations with various memory ordering options, from relaxed to sequential consistency. Atomics are crucial for building lock-free data structures and algorithms, but require careful handling to avoid subtle bugs. They're powerful tools for high-performance systems, forming the basis for Rust's higher-level concurrency primitives.

Read Article →