Rust's compile-time guarantees are a superpower for production code, but they come with a notorious side effect: slow builds. A typical CI pipeline for a mid-sized Rust project can take 10–20 minutes per push, and that number grows non-linearly as your codebase expands. When every minute of build time multiplies across your team, the cumulative drag on velocity becomes a real problem. This guide is for teams who want a fast, reliable Rust build pipeline without cargo-culting random flags or blindly copying Dockerfiles. We'll walk through a practical checklist—each item tested in real projects—covering caching, incremental compilation, test parallelization, and more. By the end, you'll have a concrete plan to cut your build times by half or more, with minimal risk.
1. Why Rust Builds Are Slow—and Why Your Pipeline Makes It Worse
The root cause is well known: Rust's compiler (rustc) performs extensive type checking, monomorphization, and LLVM optimization. But the pipeline itself often amplifies the problem. Most CI runners start from a clean slate—no incremental state, no cached dependencies. That means every commit triggers a full rebuild of your dependencies and your crate, even if you changed only one line. The result is a waste of compute and developer patience.
Another hidden factor is the way cargo resolves features and profiles. If your CI configuration doesn't match your development environment (e.g., using different target directories or profile settings), cargo may invalidate its cache and recompile everything. Similarly, using a single monolithic job for all checks—lint, test, build—forces sequential execution, leaving CPU cores idle. The good news is that these are all solvable with deliberate pipeline design.
We'll address each of these pain points in the sections ahead. The goal is not to eliminate every compile second—that's unrealistic—but to remove the low-hanging fruit that makes your pipeline feel sluggish. Let's start with the foundational layer: caching.
2. The Checklist: Caching, Incremental Compilation, and Dependency Management
Before we dive into advanced techniques, let's establish the three pillars of fast Rust CI: caching, incremental compilation, and dependency management. These are the areas where most teams can achieve 60–80% of the speed gains with minimal effort.
2.1 Cache Your Dependencies and Target Directory
The single biggest win is caching the ~/.cargo directory and the target directory between runs. Most CI providers (GitHub Actions, GitLab CI, CircleCI) offer built-in caching actions. However, the default settings often miss nuances. For example, you need to cache both the registry index and the compiled crate artifacts. A common mistake is caching only ~/.cargo/registry but not target, which forces recompilation of your own crate. Use a cache key that includes the hash of Cargo.lock so that dependency changes invalidate the cache correctly.
2.2 Enable Incremental Compilation with Care
Rust's incremental compilation can speed up rebuilds by reusing intermediate artifacts. In CI, it's often disabled by default (or set to false) because it can increase artifact size and sometimes cause cache thrashing. Our recommendation: enable it for debug builds (the default profile) but disable it for release builds. The trade-off is that incremental artifacts can be large (hundreds of MB), so ensure your cache storage limit can handle it. Also, set CARGO_INCREMENTAL=1 only for the profile you actually rebuild frequently—usually debug.
2.3 Prune Unused Dependencies and Features
Every dependency adds compile time. Use cargo-udeps to find unused crates, and audit your feature flags. Many crates have optional features that pull in heavy transitive dependencies. For example, serde with derive is fine, but serde with rc can add compile overhead. In CI, you can run a job that checks for unused dependencies and fails the pipeline if any are found. This keeps your dependency tree lean over time.
3. Parallelizing Your Pipeline: Test Suites, Lints, and Builds
Once caching is in place, the next bottleneck is often sequential execution. Most CI pipelines run cargo check, then cargo test, then cargo clippy in a single job. That's fine for small projects, but as your test suite grows, you're leaving parallelism on the table.
3.1 Split Jobs by Crate or Workspace Member
If you use a Cargo workspace, you can split your CI into separate jobs for each workspace member. This allows independent caching and parallel execution. For example, a job for core-lib and another for web-api can run simultaneously. The key is to ensure that dependent crates are built first—or that you use a tool like cargo-workspaces to manage the dependency graph. Many teams use a matrix strategy where each job builds and tests a subset of the workspace.
3.2 Parallelize Tests Within a Single Crate
Rust's test runner can run tests in parallel by default, but CI runners often have limited CPU cores. If your runner has 4 cores, you can set RUST_TEST_THREADS=4 to maximize utilization. However, beware of tests that share resources (like a database or filesystem)—they may need to run sequentially. Use #[serial_test] or a dedicated test harness for those cases.
3.3 Run Lints and Formatting in Parallel with Builds
Linting (cargo clippy) and formatting (cargo fmt --check) don't depend on build artifacts. You can run them in a separate job that only checks out the code and runs the linter, without waiting for the full build. This gives faster feedback for style issues. Some teams even run a quick cargo check on the changed files only (using cargo-check --workspace with file filters) to catch syntax errors within seconds.
4. Trade-Offs: When Fast Builds Compromise Reliability
Speed is not the only goal. A pipeline that builds in 2 minutes but occasionally produces broken artifacts is worse than one that takes 5 minutes but is rock-solid. Here are the common trade-offs you'll face.
4.1 Incremental Compilation Cache Bloat
As mentioned, incremental artifacts can be large. If your CI provider has a cache size limit (e.g., 5 GB on GitHub Actions), you might hit it and cause cache evictions. This leads to cold builds, which are slower than a clean build with no cache. The fix is to use a more granular cache key (e.g., per-branch or per-workflow) or to limit incremental to only the debug profile. Some teams disable incremental entirely in CI and rely on dependency caching alone—that's a valid trade-off if your build times are already acceptable.
4.2 Over-Parallelization and Resource Contention
Running too many parallel jobs can saturate your CI runner's CPU or memory, causing slowdowns or OOM kills. On GitHub Actions, the default runner has 2 cores and 7 GB RAM. Spinning up 4 parallel test jobs might actually increase total wall time due to context switching. Profile your pipeline: measure CPU and memory usage during peak parallel load. If you see high swap usage, reduce parallelism.
4.3 Skipping Full Builds with cargo check
Many teams use cargo check instead of cargo build in CI to speed up feedback. That's fine for type checking, but it doesn't produce a binary. If you need to deploy the artifact, you still need a full build. A common pattern is to run cargo check on every push and cargo build --release only on merges to the main branch. That saves time on feature branches while ensuring the release artifact is tested.
5. Implementation Path: From Slow to Fast in Five Steps
Here's a concrete, incremental plan to upgrade your pipeline. Don't try to do everything at once—each step builds on the previous one.
Step 1: Audit Your Current Pipeline
Start by measuring your current build times. Use the CI provider's built-in timing logs or a tool like cargo-timing. Identify the longest-running jobs and the most time-consuming steps (e.g., dependency compilation vs. your crate compilation). This gives you a baseline and helps prioritize.
Step 2: Implement Dependency Caching
Add caching for ~/.cargo and target directories. Use a cache key that includes the hash of Cargo.lock. Test that the cache is restored correctly: run a build, then a second build with no code changes, and verify that the second build takes only a few seconds (it should hit the cache). If the cache is invalidated too often, check your cache key—it might be too broad (e.g., including branch name) or too narrow (e.g., including timestamp).
Step 3: Enable Incremental Compilation for Debug Builds
Set CARGO_INCREMENTAL=1 in your CI environment for the debug profile. Monitor the cache size—if it grows beyond 1 GB, consider disabling incremental or using a separate cache for incremental artifacts. Also, ensure that your Cargo.toml doesn't have conflicting profile settings that disable incremental.
Step 4: Parallelize Your Test and Lint Jobs
Split your pipeline into at least three jobs: one for cargo check (or cargo build), one for cargo test, and one for cargo clippy and cargo fmt. Use a matrix strategy if you have a workspace. Set RUST_TEST_THREADS to match your runner's core count. Run the lint job in parallel with the build job—it doesn't need the build artifacts.
Step 5: Optimize Docker Layers (If Using Docker)
If you build Docker images, the order of layers matters. Put the dependency compilation in an early layer (before your source code) so that it's cached unless Cargo.lock changes. Use multi-stage builds to keep the final image small. For example, use a builder stage with the full Rust toolchain and a runtime stage with only the binary. This reduces push and pull times for the image.
6. Risks and Failure Modes: What Can Go Wrong
Even with careful planning, things can break. Here are the most common pitfalls and how to avoid them.
6.1 Cache Poisoning from Stale Artifacts
If your cache key is too broad (e.g., based only on branch name), you might reuse artifacts from a previous build that are incompatible with the current code. This can cause mysterious compilation errors or runtime crashes. Always include the hash of Cargo.lock and the Rust toolchain version in the cache key. If you change toolchains (e.g., from stable to nightly), invalidate the cache entirely.
6.2 Incremental Compilation Crashes
Incremental compilation is still experimental in some edge cases. You might encounter ICE (Internal Compiler Error) when using incremental with certain crate combinations. The fix is to disable incremental for that specific crate or to use CARGO_INCREMENTAL=0 as a fallback. Monitor your CI logs for ICEs and pin the Rust version if needed.
6.3 Flaky Tests Due to Parallel Execution
When you increase test parallelism, you may uncover race conditions or tests that assume sequential execution. These tests will fail intermittently, causing pipeline failures that are hard to reproduce. Use #[serial_test] for tests that share state, and run them in a separate job with RUST_TEST_THREADS=1. Alternatively, use a test harness that supports test ordering.
6.4 Overlooking Network Timeouts
If your CI runner has a slow or unreliable network connection, downloading dependencies can time out. Use a mirror or a local registry cache (like cargo-local-registry) to reduce external dependencies. Also, set appropriate timeouts in your CI configuration (e.g., CARGO_HTTP_TIMEOUT=120).
7. Mini-FAQ: Common Questions About Rust CI
Q: Should I use cargo check or cargo build in CI?
A: It depends on your goal. cargo check is faster and catches type errors, but it doesn't produce a binary. Use cargo check for quick feedback on feature branches, and cargo build --release for the final artifact on main. Some teams run both: check on every push, build on merge.
Q: How do I handle workspace-level caching?
A: Cache the entire target directory, but be aware that workspace members share a single target directory. If you split jobs by workspace member, each job will rebuild dependencies that are not shared. A better approach is to build all workspace members in a single job (with caching) and then run tests in parallel jobs that depend on the build job. This avoids redundant compilation.
Q: What's the best Rust toolchain for CI?
A: Use the stable toolchain for most projects. Nightly is needed only if you use unstable features or run cargo clippy with nightly-only lints. Pin the toolchain version in rust-toolchain.toml to avoid surprises when a new stable release changes behavior.
Q: My CI is still slow after caching. What else can I do?
A: Consider using a more powerful runner (e.g., GitHub Actions larger runner) or self-hosted runners with SSDs. Also, profile your build with cargo build --timings to see which crates take the longest. You might be able to split a large crate into smaller crates to improve parallelism. Another option is to use sccache (a distributed compiler cache) if you have multiple runners.
Q: How often should I clean the cache?
A: Set a cache retention policy (e.g., 7 days) to avoid stale artifacts. Most CI providers automatically evict old caches. You can also manually invalidate the cache by changing the cache key when you upgrade the Rust toolchain or update major dependencies.
8. Next Steps: Your Three-Move Action Plan
Stop reading and start doing. Here are three concrete moves you can make this week:
- Measure your current build time. Add a step in your CI that logs the duration of each cargo command. Use this as a baseline.
- Implement dependency caching. If you haven't already, add caching for
~/.cargoandtarget. Test with a no-change commit to confirm the cache hit. - Split your pipeline into parallel jobs. At minimum, separate linting from building. If you have a workspace, split by crate. Monitor the wall time reduction.
After these three steps, you'll likely see a 40–60% improvement. Then revisit this checklist for the next tier: incremental compilation, Docker layer optimization, and advanced parallelization. Remember, the goal is not to optimize every millisecond but to remove the friction that slows your team down. A faster pipeline means faster feedback, fewer context switches, and more time for the work that matters.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!