The $10,000 Compile: How Rust’s Build Times Kill Startups
Green checks. Merge approved. Then the build sat there, churning. No alarm. No outage. Just silence and a spinner. That day didn’t break production. It broke momentum. And momentum is what feeds a young product — and a small team. This isn’t a language fight. It’s a time bill. Rust makes services fast and safe at runtime. But the way we build Rust can quietly drain build time until sprints feel heavy. You don’t see that bill in Grafana. You feel it in the room when everyone is waiting. Where the minutes really go commit → resolve deps → compile (check → codegen) → link/LTO → tests → package → image → deploy
↑ ↑ ↑
macros/IDL generics fan-out big link step
Each arrow hides a choice you made months ago: a crate graph that grew wide, derives that felt convenient, features set to “everything.” The root of the pain in plain words Monomorphization. Every new T for a generic function can produce new machine code. That’s great for speed where it counts, but terrible when multiplied across a deep tree. Proc-macros and derives. They save keystrokes and cost build work. Pile enough of them across crates and you pay every clean CI run. Feature sprawl. A single "full" flag can pull in an entire universe you didn’t mean to compile today. Linker pressure. Heavier inlining and LTO can win in prod and punish in PR builds. If you pay that price on every pull request, you’re budgeting wrong. The small design change that gave the time back The trick wasn’t heroic. We kept generics where they’re truly hot and used trait objects at the edges where we don’t need fan-out. Edges are where the build explodes. Edges are where we made it boring. A concrete example (service rules with a stable boundary) // src/validator.rs use std::sync::Arc; use regex::Regex;
// A small object-safe trait for the boundary. Stable. Testable. pub trait Rule: Send + Sync {
fn name(&self) -> &'static str; fn check(&self, input: &str) -> bool;
}
// Concrete rules stay fast inside. pub struct RegexRule { re: Regex } impl RegexRule {
pub fn new(pattern: &str) -> Self { Self { re: Regex::new(pattern).unwrap() } }
} impl Rule for RegexRule {
fn name(&self) -> &'static str { "regex" }
fn check(&self, input: &str) -> bool { self.re.is_match(input) }
}
pub struct RangeRule { lo: u64, hi: u64 } impl Rule for RangeRule {
fn name(&self) -> &'static str { "range" }
fn check(&self, s: &str) -> bool {
s.parse::<u64>().map(|v| self.lo <= v && v <= self.hi).unwrap_or(false)
}
}
// The service depends on dyn, not generics, so one code path compiles at the edge. pub struct Validator { rules: Vec<Arc<dyn Rule>> } impl Validator {
pub fn new(rules: Vec<Arc<dyn Rule>>) -> Self { Self { rules } }
pub fn validate(&self, s: &str) -> bool { self.rules.iter().all(|r| r.check(s)) }
} Why this felt like someone opened a window:
- The service doesn’t recompile a fresh path for every T.
- The hot parts remain hot inside each rule.
- The boundary stays stable even as the set of rules grows.
You trade a handful of nanoseconds for minutes of build time and calmer links. That’s a trade a product team can understand. A hand-drawn look at the blast radius BEFORE (generic edges → many code paths) ┌───────────────┐ many T ⇒ many code units │ Validator<T> │──┬──────────────────────────────┐ └───────┬───────┘ │ │
│ ┌────▼──────────┐ ┌────────────▼──────┐
│ │ Rule<T_user> │ ... │ Rule<T_amount> │ (fan-out)
│ └───────────────┘ └───────────────────┘
AFTER (dyn edges → one code path at the boundary) ┌───────────────┐ │ Validator │────▶ Box<dyn Rule> ──▶ concrete rules (fast inside) └───────────────┘
│ └───────────────┘ └───────────────────┘
When the edge stops multiplying, the linker stops groaning. And people stop refreshing the CI page. “But dynamic dispatch is slower.” Sometimes. At the edge, the cost is microscopic compared to the build time and the mental stalls during the day. Keep generics where they burn CPU in a loop. Use dyn Trait where the code graph branches wide and correctness is about interfaces, not micro-ops. This is not a belief system. It’s a budget. Spend speed where speed matters and spend simplicity where scale explodes. Profiles without turning the article into a checklist In development and on pull requests, you want feedback quickly. Fast codegen units and no heavy link-time passes are your friend there. For release, choose deliberately: if cold start matters more than raw throughput, accept smaller binaries and milder inlining; if throughput rules, enable the heavy passes where you ship. The important part isn’t the exact knobs. It’s the agreement in the team about where you pay and when you pay. Features that don’t surprise you at the merge button Feature flags are power. Power becomes chaos when a single on switch pulls half the registry. Give features names that match what you deploy. Keep them few. Decide them in one place so every crate doesn’t invent a new universe. When a feature is only for measurement or one-off experiments, keep it away from everyday builds. No drama. No heroics. Just less to compile. Proc-macros and the sugar bill The magic is delightful. A blanket derive can unfold into real work on every clean build across many crates. Keep those macros close to the leaves where you pay once. If a schema generator has already created code, version that output and reuse it instead of regenerating the same forest every time. You still get the expressive code. You just stop recompiling the world. A short story from the quiet room The worst moment wasn’t a pager. It was a dozen engineers pretending to read messages because nobody wants to admit we’re all waiting. There was no hero move to make. Only a design choice at the edges, a few profile decisions, and a conversation about features that actually matched what we deploy. We didn’t change the language. We changed where we let it be powerful. The room got louder again. Another sketch for your mental model BUILD PRESSURE MAP [Features]─────┐
├─▶ expands crates to compile
[Proc-macros]──┘
[Generics at edges]──▶ copies code across types ──▶ [Bigger link step] [Generics inside hot loops]──▶ worth it ──────────▶ [Runtime wins]
[Dyn at edges]──▶ one path ─▶ [Smaller link] ─▶ [Faster PR builds] You don’t need a lab to use this map. You need a conversation and a small refactor. What to say when someone asks “Is Rust too slow to build?” Say this: Rust isn’t slow. Our choices are expensive. Then explain how edges can be stable, how features can be named for reality, and how release builds can be strong without dragging every pull request through a heavy linker. You’ll watch the debate shift from emotion to design. That’s when you start getting your minutes back. If you’re tempted to measure Use your own CI. You don’t need synthetic numbers here. The proof that convinces a team is the before/after on the builds they already run. Capture a clean run, make the edge change, capture another clean run. Show the difference. End the meeting. CTA Rust gave us safety and speed in production. We lost hours in the way we compiled it. By keeping generics where they win and switching the boundary to trait objects, the code stayed expressive, the binaries stayed strong, and the build felt human again. The secret wasn’t a new crate or a dramatic rewrite. It was treating compile time as a first-class budget and spending it where it returns attention, flow, and ship energy. If this landed for you, tell me the part of your build that hurts the most. I’ll read every story. The next piece I write will use your pain as the map.
Read the full article here: https://medium.com/@samurai.stateless.coder/the-10-000-compile-how-rusts-build-times-kill-startups-ac77e5f102e2