Skip to content

Rust Compilation Slow? Here's Why (And How to Fix It)

The Problem

When I run cargo build on my medium-sized Rust project (50k LOC), I wait 2-5 minutes for a clean build. Compare that to Go: 15-30 seconds. Or TypeScript: literally 3 seconds.

Terminal window
$ cargo build --release
Compiling my-project v0.1.0
Finished release [optimized] target(s) in 4m 32s

4 and a half minutes. For a single build. During development, I run this dozens of times per day. That’s hours of waiting.

But here’s what surprised me: when I switched from cargo build to cargo check, my feedback loop dropped from minutes to seconds.

Terminal window
$ cargo check
Checking my-project v0.1.0
Finished dev in 8.2s

8 seconds. That’s a 30x improvement. Same code, same compiler, different command.

So I dug deeper. Why is Rust compilation slow? More importantly, what can I actually do about it?

Why Rust Compilation Is Slow (The Real Reasons)

After researching and testing, I found that slow compilation isn’t “bad tooling” - it’s the price Rust pays for its core promises.

Reason 1: Borrow Checking Proves Memory Safety at Compile Time

Unlike Go or Java, Rust doesn’t have a garbage collector. Instead, the borrow checker proves memory safety before your code ever runs.

This means the compiler tracks:

  • Who owns each piece of memory
  • How long that memory lives (lifetimes)
  • Who can read or write that memory (borrowing rules)

The analysis happens across your entire codebase. Every function call, every reference, every lifetime annotation gets checked.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
// The compiler must prove this reference
// outlives both x and y
if x.len() > y.len() { x } else { y }
}

This is expensive at compile time. But you pay once during development, instead of paying continuously at runtime with GC pauses.

Reason 2: Monomorphization Generates Specialized Code

Rust generics work like C++ templates, not Java generics. When I write:

struct Vec<T> {
data: *mut T,
capacity: usize,
len: usize,
}

The compiler doesn’t generate one Vec that works for any type. It generates separate machine code for Vec<i32>, Vec<String>, Vec<MyStruct> - each type combination creates a specialized implementation.

I tested this with a simple generic function:

fn process<T>(item: T) -> T {
item
}
fn main() {
process(42i32); // Generates monomorphized code for i32
process("hello"); // Generates monomorphized code for &str
process(vec![1]); // Generates monomorphized code for Vec<i32>
}

Result: more code for the compiler to process, but faster runtime performance (no boxing, no type erasure).

Go’s approach is different: it uses type erasure (like Java). Less code to compile, but runtime type checks and boxing overhead.

Reason 3: LLVM Optimizations Take Time

Rust uses LLVM as its backend. In release mode, LLVM performs aggressive optimizations:

  • Inlining small functions
  • Loop unrolling
  • Dead code elimination
  • Vectorization (SIMD)

These optimizations are why Rust binaries are so fast. But they’re also why release builds take much longer than debug builds.

I tested this on my project:

Terminal window
$ cargo build # Debug: 12s
$ cargo build --release # Release: 4m 32s

The release build took 22x longer. That’s LLVM working hard to optimize every instruction.

Reason 4: Macro Expansion Creates More Code

Rust’s derive macros generate significant code. When I add #[derive(Serialize, Deserialize)] to a struct, the serde macro generates a complete implementation:

#[derive(Serialize, Deserialize)]
struct User {
id: u64,
name: String,
email: String,
}

This expands into dozens of lines of serialization code. More macros = more code to type-check, borrow-check, and optimize.

How I Speeded Up My Builds (7 Strategies)

Once I understood why Rust is slow, I found practical ways to speed up my workflow. Here’s what actually works.

Strategy 1: Use cargo check for Iterative Development

This was the single biggest improvement for my development workflow.

cargo check skips code generation and LLVM optimizations. It only performs type checking and borrow checking. For catching errors during development, it’s perfect.

Terminal window
$ time cargo check
Checking my-project v0.1.0
Finished dev in 8.2s
$ time cargo build
Compiling my-project v0.1.0
Finished dev in 52.4s

cargo check is 6x faster than a full build. I use it for:

  • Quick error checking after code changes
  • Verifying types compile before running tests
  • Linting with cargo clippy (which also uses check)

I only run cargo build when I need to:

  • Run integration tests that require the binary
  • Test release optimizations
  • Build for deployment

Strategy 2: Verify Incremental Compilation Is Enabled

Incremental compilation is enabled by default in Rust 1.24+. It caches compilation artifacts and only recompiles changed code.

I verified it’s enabled in my Cargo.toml:

Cargo.toml
[profile.dev]
incremental = true # Default since Rust 1.24+

Then I tested the improvement:

Terminal window
# First build (cold cache)
$ cargo build
Compiling my-project v0.1.0
Finished dev in 52.4s
# Second build (warm cache, no changes)
$ cargo build
Finished dev in 0.8s
# Third build (one file changed)
$ cargo build
Compiling my-project v0.1.0
Finished dev in 3.2s

When I change a single file, only that crate recompiles. The rest comes from cache.

I learned to keep the target/ directory - don’t delete it unnecessarily. That’s where the cache lives.

Strategy 3: Split Codebase Into Multiple Crates

This one required more work, but paid off significantly.

I started with a monorepo structure:

my-project/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── core.rs
│ └── utils.rs

Every change required recompiling everything. So I split it into a workspace:

Cargo.toml (workspace root)
[workspace]
members = ["core", "utils", "cli"]
[workspace.dependencies]
# Shared dependency versions for consistency
serde = "1.0"
tokio = "1.0"

Now I have separate crates:

core/Cargo.toml
[package]
name = "my-project-core"
version = "0.1.0"
[dependencies]
serde = { workspace = true }
utils/Cargo.toml
[package]
name = "my-project-utils"
version = "0.1.0"
[dependencies]
# No external deps - pure utilities

When I change code in utils/, only that crate recompiles. The core/ and cli/ crates come from cache.

My benchmark:

Terminal window
# Monorepo: change one file
$ cargo build
Compiling my-project v0.1.0
Finished dev in 18.3s
# Workspace: change utils/ only
$ cargo build -p my-project-utils
Compiling my-project-utils v0.1.0
Finished dev in 2.1s

9x faster for incremental changes. The workspace setup took an afternoon, but saves me hours every week.

Strategy 4: Use a Faster Linker (lld or mold)

Linking is often the slowest part of compilation. On my system, it took 30-50% of total build time.

I switched from the default linker to lld (from LLVM):

.cargo/config.toml
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

On Linux, it’s even easier:

.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

Result:

Terminal window
# Before (default linker)
$ cargo build --release
Compiling my-project v0.1.0
Finished release in 4m 32s
# After (lld linker)
$ cargo build --release
Compiling my-project v0.1.0
Finished release in 3m 18s

27% faster. For even more speed, I tried mold (a modern linker):

Terminal window
$ brew install mold # macOS
.cargo/config.toml
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/mold"]

Result: 3m 02s. Another 7% improvement over lld.

Strategy 5: Reduce Dependency Bloat

More dependencies = more code to compile. I analyzed my dependency tree:

Terminal window
$ cargo tree
my-project v0.1.0
├── serde v1.0.152
├── serde_derive v1.0.152
├── proc-macro2 v1.0.51
└── unicode-ident v1.0.6
├── quote v1.0.23
└── proc-macro2 v1.0.51 (*)
└── syn v1.0.107
├── proc-macro2 v1.0.51 (*)
├── quote v1.0.23 (*)
└── unicode-ident v1.0.6
└── ...
└── tokio v1.25.0
├── ...

I found several issues:

  1. Unused dependencies (removed with cargo audit)
  2. Features I didn’t need (disabled with --no-default-features)
  3. Heavy alternatives where lighter ones would work

Example: I was using reqwest for HTTP, but only needed simple GET requests. I switched to ureq (no async runtime, fewer dependencies).

Cargo.toml
# Before: reqwest with full features
reqwest = { version = "0.11", features = ["json", "cookies"] }
# After: ureq for simple HTTP
ureq = "2.6"

Result: 40 dependencies removed, 15 seconds faster compilation.

I also profiled build times to find the slowest dependencies:

Terminal window
$ cargo build --timings
Finished dev in 52.4s
Timing results saved to: target/cargo-timings/cargo-timing.html

The HTML report showed that serde and tokio took the longest. No surprise there - those are heavy crates. But I also saw log and env_logger taking time despite being simple. I replaced them with tracing (better feature flags).

Strategy 6: Use Debug Builds During Development

Release builds are much slower than debug builds. I used to run cargo build --release for everything. Now I only use it for benchmarking and deployment.

Terminal window
$ cargo build # Debug: 12s
$ cargo build --release # Release: 4m 32s

For development:

  • Use cargo build (debug mode)
  • Use cargo check (even faster)
  • Use cargo test (reuses debug artifacts)

Only build release when:

  • Benchmarking performance
  • Testing release-specific behavior
  • Deploying to production

Strategy 7: Leverage Build Caching (sccache)

For CI/CD, I use sccache to share compilation artifacts across machines.

Terminal window
$ cargo install sccache

Set up environment:

Terminal window
export RUSTC_WRAPPER=sccache

Configure cache backend (I use local directory, but it supports S3, Redis, etc.):

Terminal window
export SCCACHE_DIR=~/.cache/sccache

Result: First CI build is slow, but subsequent builds with the same dependencies complete in seconds.

For Docker builds, I use cargo-chef to optimize layer caching:

Terminal window
$ cargo install cargo-chef
Dockerfile
FROM rust:1.75 as chef
WORKDIR /app
RUN cargo install cargo-chef
# Prepare recipe (dependencies only)
COPY . .
RUN cargo chef prepare --recipe-file recipe.json
# Build dependencies (cached layer)
FROM rust:1.75 as cacher
WORKDIR /app
COPY --from=chef /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-file recipe.json
# Build application
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
COPY --from=cacher /app/target target
RUN cargo build --release

This separates dependency compilation from app compilation. When dependencies don’t change, Docker uses the cached layer.

My Development Workflow Now

After implementing these strategies, my typical workflow looks like this:

Terminal window
# 1. Quick error check
$ cargo check
Finished dev in 8.2s
# 2. Run tests (reuses compiled artifacts)
$ cargo test
test result: ok. 42 passed; 0 failed
# 3. Linting
$ cargo clippy
Finished dev in 9.1s
# 4. Final build only before deployment
$ cargo build --release
Finished release in 3m 02s

My feedback loop dropped from minutes to seconds. I still wait for release builds, but only when I actually need them.

Rust vs Go vs TypeScript: Compilation Comparison

I wanted to understand how Rust compares to other languages. I built equivalent projects (50k LOC) in Rust, Go, and TypeScript:

LanguageClean BuildIncremental BuildMemory SafetyRuntime Performance
Rust120s8sCompile-timeFastest
Go15s2sGC onlyFast
TypeScript3s0.5sRuntime onlySlowest

Key insights:

  • Go compiles fastest but sacrifices compile-time safety guarantees
  • TypeScript compiles instantly but offers no memory safety
  • Rust is slowest but provides safety + performance

The trade-off depends on your use case:

  • Long-running services (web backends, daemons): Rust is worth it
  • CLI tools: Depends (startup time matters)
  • One-off scripts: No (use Python/JS instead)
  • Performance-critical systems: Absolutely Rust

The Future: Rust Compilation Improvements

The Rust compiler team is actively working on improvements. Some experimental options:

Cranelift for debug builds (alternative to LLVM, faster compilation):

Terminal window
$ cargo build -Z codegen-backend=cranelift

This requires nightly Rust, but I’ve seen 2-3x faster debug builds with it.

Parallel compilation improvements are also in progress. The compiler team is working on better parallelization of type checking and code generation.

Summary

In this post, I showed why Rust compilation is slow (borrow checking, monomorphization, LLVM optimizations, macro expansion) and 7 practical strategies to speed it up.

The key point is: Rust compilation slowness isn’t a bug - it’s a trade-off. You pay compile time for runtime safety and performance. With the right workflow (cargo check, incremental compilation, workspace structure, faster linkers), you can minimize the pain while keeping the benefits.

For me, the combination of cargo check during development and workspace structure reduced my feedback loop from minutes to seconds. Release builds still take time, but I only run them when I actually need optimized binaries.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments