December 18, 2024
How I Reduced Our Package Build Time by 95%
My director pulled me aside last month with a problem: other teams were complaining that our package build time was too long. At 60 minutes per build, it had become a bottleneck for the entire organization. Teams depending on our package were stuck waiting, and it was slowing down everyone's deployment cycles.
He asked me to look into it. I wasn't thrilled—build optimization can be a rabbit hole—but I said I'd take a look. What I found surprised me, and the results surprised everyone else.
Step 1: Remove the Dead Weight
The first thing I noticed was that we were building two Ruby versions sequentially: Ruby 2.5 and Ruby 3.x. The problem? Ruby 2.5 had been deprecated for a while, and no team was actually using it anymore. We were spending half our build time on a version nobody needed.
I confirmed with the relevant stakeholders that Ruby 2.5 could be dropped, then removed it from the build matrix. Just like that, build time went from 60 minutes to 30 minutes. A 50% improvement from deleting code.
Lesson: Before optimizing anything, check if you're even building the right things. Dead code and deprecated targets are the easiest wins.
Step 2: Parallelize the Tests
With the low-hanging fruit gone, I looked at where the remaining 30 minutes were being spent. The answer was obvious: tests. Our test suite was running sequentially, one test after another, on a single thread.
I restructured the test runner to parallelize across multiple workers. Our CI environment had the compute available—we just weren't using it. After implementing parallel test execution, build time dropped from 30 minutes to 15 minutes.
Lesson: If your tests are running sequentially, you're leaving performance on the table. Most modern test frameworks support parallelization out of the box.
Step 3: Cache the I/O
At 15 minutes, the build was already much better. But I kept thinking: can I do better?
I profiled the test runs and found that a significant portion of time was spent on I/O operations—reading fixture files, loading test data, and other disk operations. The test data wasn't huge (around 250MB), but disk I/O adds up when you're doing it thousands of times across parallel workers.
I implemented an in-memory cache that loaded the test fixtures once at the start of the test run and kept them in memory for all subsequent reads. The impact was dramatic: build time dropped from 15 minutes to about 2-3 minutes.
Lesson: I/O is often the hidden bottleneck. If your tests read the same data repeatedly, caching it in memory can yield massive improvements.
The Results
Total improvement: 60 minutes → 3 minutes. A 95% reduction in build time.
I pushed the changes and sent out an email explaining what I'd done. The response was immediate. Engineers across the org were thrilled—not just because the shared package built faster, but because the same optimizations meant local builds were faster too. What used to be a coffee-break-length wait was now barely enough time to check Slack.
Why This Matters
Fast builds aren't just a nice-to-have. They fundamentally change how people work:
- Faster feedback loops: Engineers can iterate quickly, catch bugs earlier, and ship with more confidence.
- Less context switching: A 3-minute build means you can stay focused. A 60-minute build means you context-switch to something else and lose flow.
- Unblocked teams: When your package is a dependency for other teams, your build time is their build time. Faster builds help everyone.
- Lower CI costs: Less compute time means lower bills. At scale, a 95% reduction in build time translates to real money saved.
Takeaways
If you're dealing with slow builds, here's the playbook:
- Audit what you're building. Are there deprecated targets, unused configurations, or dead code paths? Remove them.
- Parallelize. If your tests or build steps run sequentially, parallelize them. Modern CI systems have the compute—use it.
- Profile and cache. Find where time is actually being spent. If it's I/O, cache aggressively. If it's CPU, consider whether the work is actually necessary.
Sometimes the biggest wins come from the simplest changes. You don't always need a fancy new tool or a complete rewrite. Sometimes you just need to delete a deprecated Ruby version and add a cache.