Waiting for Rust to compile is a familiar pain. Incremental compilation helps, but when the cache is misconfigured, you can end up rebuilding the same dependencies repeatedly. This guide is for Rust developers who want a practical, no-nonsense workflow for managing the build cache—whether you're working on a personal project or a team monorepo. We'll focus on what actually works, what doesn't, and how to diagnose cache problems without cargo culting.
The Real-World Cost of Cache Misses
In a typical project, a full rebuild of dependencies can take anywhere from a few minutes to over half an hour. For a team of five developers waiting on CI, that's hours of cumulative delay per day. But the cost isn't just time—it's also context switching. A developer who has to wait three minutes for a test iteration is likely to check email or Slack, breaking flow.
The Rust compiler's incremental compilation system is designed to reuse artifacts from previous builds. It tracks dependencies at the function level and only recompiles what changed. However, the cache is not a magic bullet. It has strict rules about when it can reuse artifacts. If a dependency's source code changes, or even if the compiler version changes, the cache is invalidated. This is where many teams get tripped up: they assume the cache will handle everything, but they don't account for factors like environment differences between machines or CI runners.
Consider a common scenario: your CI pipeline runs on ephemeral VMs. Each build starts from scratch, and the cache is stored in a shared volume. If that volume is slow or has high latency, the time saved by caching can be eaten up by I/O waits. We've seen projects where switching from a network filesystem to a local SSD cache reduced build times by 40%. The lesson: cache location matters as much as cache content.
What We Mean by Cache Workflow
A cache workflow isn't just about turning on incremental compilation. It's about understanding the full pipeline: how artifacts are stored, how they're invalidated, and how they're shared across environments. For Rust, the key components are cargo's incremental compilation, sccache (a distributed compiler cache), and sometimes a remote cache service like AWS S3 or a self-hosted Redis. Each has trade-offs.
This section sets the stage for the checklist. The goal is to give you a mental model of where cache hits and misses occur, so you can apply the steps in later sections to your specific setup.
Foundations: What Most Developers Get Wrong
The most common misconception is that incremental compilation and sccache are interchangeable. They are not. Incremental compilation works at the function level within a crate; sccache caches entire compilation units (e.g., object files). They complement each other, but they have different invalidation triggers. Incremental compilation is invalidated by changes to source code within the same crate; sccache is invalidated by changes to the preprocessed source (including macro expansions) or compiler flags.
Another frequent mistake is ignoring the compiler version. If you upgrade Rust from 1.70 to 1.71, all cached artifacts from the previous version become stale. This is because the internal representation of the compiler may change. Teams that pin toolchain versions across machines avoid this, but many don't, leading to mysterious cache misses.
Then there's the issue of environment variables. The Rust compiler includes certain environment variables in the cache key, like CARGO_MANIFEST_DIR and TARGET. If your CI and local machines have different target directories, the cache won't share. Similarly, if you use features flags inconsistently (e.g., enabling a feature in one build but not another), the cache key changes.
The Cache Key: What's Really Being Hashed
Understanding the cache key is crucial. For cargo's incremental compilation, the key is based on the crate's source code, its dependencies' source code, the compiler version, and the target triple. For sccache, the key includes the preprocessed source, compiler version, and command-line flags. This means that even a whitespace change in a dependency can invalidate the cache if it changes the preprocessed output—though in practice, Rust's AST-based approach is more resilient.
Teams often overlook the fact that debug vs. release builds produce different cache keys. If you run cargo build (debug) and cargo build --release in the same project, the two caches are separate. This is usually desirable, but it means you can't share a debug cache for a release build.
Common Setup Pitfalls
One pitfall is using a network filesystem (NFS) for the cache directory. NFS has high latency and poor locking behavior, which can cause cargo to fail when multiple processes try to write to the cache simultaneously. We recommend using a local SSD for the cache directory on each machine, and only using a remote cache (like sccache with a cloud backend) for sharing across CI runners, with careful handling of concurrent access.
Another pitfall is not cleaning the cache periodically. Over time, the cache can grow to tens of gigabytes, eating disk space and slowing down cache lookups. Set a size limit or use a tool like cargo-cache to prune old entries.
Patterns That Usually Work
After observing many Rust projects, we've distilled a set of patterns that consistently reduce build times. These are not silver bullets, but they form a reliable baseline.
1. Use a Local SSD for the Cache Directory
This is the single biggest improvement you can make. On Linux, set CARGO_TARGET_DIR to a directory on a fast SSD. On macOS, the default location is usually fine, but avoid external drives. On CI, use the ephemeral storage provided by the runner (e.g., GitHub Actions uses /tmp).
2. Pin Your Rust Toolchain
Use a rust-toolchain.toml file to specify the exact version of Rust. This ensures that all developers and CI use the same compiler, preventing cache invalidation due to version mismatches. It also makes builds reproducible.
3. Enable Incremental Compilation
This is on by default for debug builds, but for release builds, you need to explicitly set incremental = true in Cargo.toml. Be aware that incremental compilation can increase binary size slightly, but for development speed, it's worth it.
4. Use sccache for CI (and optionally locally)
sccache acts as a shared cache across CI runners. Configure it with a cloud storage backend (S3, GCS, or Azure Blob). For local development, sccache is less necessary unless you frequently switch between projects or clean builds. The overhead of sccache can sometimes be higher than just using local incremental compilation.
5. Share Dependencies Across Workspace Members
If you have a workspace with multiple crates, use a common target directory. This allows dependencies to be compiled once and reused across workspace members. The trick is to set the target-dir in .cargo/config.toml to a path outside the workspace, so each member doesn't create its own copy.
6. Profile Your Build with cargo build --timings
This generates an HTML report showing which crates are taking the longest to compile. It helps identify bottlenecks—maybe a large crate that could be split, or a dependency that's being rebuilt unnecessarily. Use this data to decide where to focus optimization efforts.
Anti-Patterns: Why Teams Revert to Slow Builds
Even with the best intentions, teams often slip into habits that defeat the cache. Here are the anti-patterns we see most often, and how to avoid them.
Anti-Pattern 1: Using a Network Filesystem for the Cache
We mentioned this earlier, but it's worth reiterating. NFS and similar filesystems have poor performance with many small files—exactly what a build cache consists of. The result is that cache hits are slower than a full rebuild. If you must use a shared cache, use sccache with a remote backend, not a network filesystem.
Anti-Pattern 2: Not Pinpointing Environment Variables
Some environment variables change the cache key. For example, if you set CARGO_INCREMENTAL=0 in one build but not another, the cache won't be shared. The fix is to be consistent: either always set it or never set it. Similarly, avoid using different target directories for the same project across machines.
Anti-Pattern 3: Frequent cargo clean
Some developers run cargo clean out of habit when they encounter a strange compilation error. This is almost always unnecessary. Instead, try cargo clean -p <crate> to selectively clean a single crate, or use cargo build --force to recompile only the affected crate. Cleaning everything throws away all cached artifacts and forces a full rebuild.
Anti-Pattern 4: Using --release for Every Build
Release builds disable incremental compilation by default, and they apply optimizations that take longer. For day-to-day development, use cargo build (debug). Reserve release builds for CI and final testing. If you need performance testing, use a separate profile with optimizations but incremental enabled.
Anti-Pattern 5: Ignoring Cache Size
A cache that grows unbounded can slow down the filesystem and cause disk space issues. Set a maximum size for the cache directory. On Linux, you can use tmpfs for the cache to limit it to RAM, but be aware of memory usage. For sccache, set the cache size limit via its configuration.
Maintenance, Drift, and Long-Term Costs
A build cache is not a set-it-and-forget-it solution. Over time, several factors can cause the cache to become less effective, requiring periodic maintenance.
Compiler Upgrades
Each Rust release may change the compiler's internal representation, invalidating the entire cache. When you upgrade the toolchain, plan for a slow first build. To mitigate this, consider using a stable release channel and upgrading only when necessary. Some teams use a separate cache directory per compiler version, so they can roll back without losing the old cache.
Dependency Updates
When you update a dependency, its source code changes, invalidating the cache for that crate and all crates that depend on it. This is expected, but if you update dependencies very frequently, you'll see fewer cache hits. Batch dependency updates into a single commit to minimize the number of times the cache is invalidated.
Build Scripts and Feature Flags
Build scripts (build.rs) can generate code that affects the cache. If a build script's output changes, the cache for that crate is invalidated. Similarly, feature flags can cause different code paths to be compiled. To avoid surprises, keep build scripts deterministic and avoid conditional compilation based on environment variables that change frequently.
Disk Space Management
On CI, ephemeral runners often have limited disk space. If the cache grows too large, the build may fail due to disk full. Set a maximum cache size in sccache or use a cron job to clean old entries. For local development, use cargo-cache to prune the cache periodically.
Team Coordination
If your team uses a shared remote cache (e.g., sccache with S3), you need to ensure that everyone is using the same cache configuration. A mismatch in cache keys means no sharing. Document the expected environment variables and toolchain version, and use a configuration file in the repository.
When Not to Use This Approach
The cache workflow we've described is not universal. There are scenarios where it adds complexity without benefit, or where it actively hurts.
When You're Prototyping Solo
If you're the only developer on a small project, and you rarely clean your build directory, the default incremental compilation is probably sufficient. Adding sccache or a shared remote cache is overkill. Focus on writing code, not on optimizing build times that are already under a minute.
When Your Build Is Already Under 30 Seconds
If your project compiles quickly, the overhead of managing a cache (especially a remote one) may outweigh the time saved. Measure your build time before and after introducing a cache. If the improvement is marginal, skip it.
When You're Using a CI That Doesn't Support Persistent Storage
Some CI providers don't offer persistent storage between builds, or charge extra for it. In that case, a remote cache can still help, but you'll need to set up your own storage (e.g., S3). If that's too much effort, consider using a CI service that caches the cargo directory by default (like GitHub Actions with actions/cache).
When You're Working on a Very Large Codebase with Many Dependencies
In a monorepo with hundreds of crates, the cache key space becomes large, and the probability of a cache hit decreases. In such cases, you might need a more sophisticated caching strategy, like using a build system that supports distributed compilation (e.g., Bazel). The simple cargo-based approach may not scale.
When You're Using Unstable Compiler Features
If you rely on nightly Rust or unstable features, the cache is more likely to be invalidated by compiler changes. In that case, you might decide to accept slower builds in exchange for access to the latest features.
Open Questions / FAQ
We've collected some of the most common questions from developers who have tried to optimize their Rust build cache. These don't have one-size-fits-all answers, but we'll give you the trade-offs to consider.
Should I use sccache locally or only on CI?
For local development, sccache adds overhead because it has to check the remote cache on every compilation. If you have a fast local disk, incremental compilation is usually faster. Use sccache locally only if you frequently switch between projects or clean builds, and you have a low-latency remote cache (e.g., a local Redis instance).
How large can the cache grow before it becomes a problem?
There's no hard limit, but a cache larger than 10 GB can start to slow down file operations, especially on spinning disks. Set a maximum size of 5–10 GB for the local cache. For remote caches, the storage cost may be a concern; monitor usage and set a lifecycle policy to delete old entries.
Does incremental compilation affect binary size or performance?
Yes, incremental compilation can produce larger binaries because it doesn't optimize across function boundaries as aggressively. For release builds, you should disable incremental compilation (the default) to get smaller and faster binaries. For debug builds, the size increase is usually acceptable.
Can I share the cache across different operating systems?
No, because the compiled artifacts are platform-specific. The cache key includes the target triple, so a build for x86_64-unknown-linux-gnu cannot reuse artifacts from x86_64-apple-darwin. You need separate caches per platform.
What should I do if I suspect a cache corruption?
First, try cargo clean -p <crate> for the specific crate that's failing. If that doesn't work, do a full cargo clean and rebuild. To prevent corruption, avoid killing the build process mid-compilation, and use a reliable filesystem (not NFS).
Next Steps: Your 3-Minute Checklist
Here's a concrete set of actions to take after reading this guide:
- Run cargo build --timings on your project and note which crates take the longest. This gives you a baseline.
- Check if you're using a network filesystem for the cache. If yes, move it to a local SSD.
- Add a rust-toolchain.toml file to your repository if you don't have one. Pin to a stable version.
- Configure sccache for CI with a remote backend (S3, GCS, or Azure). Test that cache hits work across different runners.
- Set a maximum cache size: for local, use cargo-cache or a cron job; for sccache, set the cache-size config.
- Review your team's environment variables and ensure they are consistent across machines. Document the required ones.
- Schedule a monthly review of cache performance. If build times have crept up, investigate what changed (toolchain upgrade? dependency changes?).
With these steps, you'll have a cache workflow that saves time without adding unnecessary complexity. Remember, the goal is not to achieve the fastest possible build at any cost, but to eliminate the worst slowdowns that break your flow. Start with the simplest changes—local SSD and pinned toolchain—and only add remote caching if you need it.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!