Caching Strategy for Flutter Dependencies in Continuous Integration

By Adaobi Chima · 17 August 20267,948 views
Caching Strategy for Flutter Dependencies in Continuous Integration

Introduction: The Hidden Cost of Flutter CI/CD

In the context of an agritech startup based in Imo State, every bit of bandwidth matters. When we scale our crop price aggregation app to 50,000 farmers, the efficiency of our backend is crucial, but our CI/CD pipeline efficiency is equally vital for developer velocity. Early on, our GitHub Actions pipeline was consuming nearly six minutes per build just to fetch dependencies from pub.dev. For a team pushing code multiple times a day, this meant hours lost to network latency and redundant HTTP requests.

Standard Flutter CI configurations often ignore the cost of dependency fetching. By default, every job initiates a clean flutter pub get. Over a month, this doesn't just inflate our cloud provider's egress costs; it creates a feedback loop that discourages frequent commits. In this article, we will examine how to implement a robust, granular caching strategy for Flutter dependencies that reduces build times by over 70% and significantly slashes bandwidth consumption.

The Baseline: Measuring the Bloat

Before implementing any optimization, we must establish a baseline. In our initial setup, a standard GitHub Actions runner performed the following steps:

  1. Check out repository.
  2. Set up Flutter SDK (itself a heavy process).
  3. flutter pub get.
  4. Build the release binary.

Without caching, flutter pub get pulls the entirety of the dependency tree, including transitive dependencies, from the Pub registry. For our primary app, this amounted to approximately 140MB of data per build. With five developers pushing three times a day, that is 2.1GB of redundant egress daily. More importantly, the execution time for this stage ranged between 180 and 350 seconds depending on the region's congestion.

To identify the bottleneck, we used a simple timer in our workflow file. By isolating the pub get phase, it became clear that we were spending more time waiting for the network than actually compiling code. The goal was simple: turn an O(N) network-dependent operation into an O(1) local read operation.

Step-by-Step Implementation of Cache Keys

To move away from fresh fetches, we must leverage the actions/cache GitHub Action. The logic involves mapping a specific key to a specific directory (.pub-cache). If the key exists, the CI runner restores the cache from storage; if not, it proceeds with the download and updates the cache at the end of the job.

1. Configure the Workflow

Below is the implementation that transformed our CI pipeline. We target the pub-cache directory, which stores the global cache for all Dart packages.

- name: Cache Flutter dependencies
  uses: actions/cache@v3
  with:
    path: ~/.pub-cache
    key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
    restore-keys: |
      ${{ runner.os }}-pub-

2. Explain the Strategy

  • Pathing: We point directly to ~/.pub-cache. This is the home of all downloaded packages. By persisting this, we ensure that the next build run finds previously downloaded artifacts.
  • The Hash key: Using hashFiles('**/pubspec.lock') is non-negotiable. If you hash the pubspec.yaml, the cache will invalidate every time you add a simple metadata change that doesn't actually affect the dependency tree. The pubspec.lock ensures the cache only invalidates when a version constraint or a dependency package is explicitly updated.
  • Restore Keys: We provide a prefix key. If a specific pubspec.lock change causes a cache miss, we don't start from zero. The runner falls back to the most recent cache available for that OS, which significantly reduces the amount of data that needs to be downloaded to synchronize the cache.

Optimization Metrics: Before and After

After applying this caching strategy across our repositories, the results were measurable and immediate. We observed a consistent reduction in build duration across all CI runners.

MetricBaselineOptimizedImprovement
Network Fetch Time245 seconds18 seconds~93%
Data Egress140MB~8MB (diff)~94%
Total CI Job Time412 seconds175 seconds~57%

For a startup where bandwidth is precious, the 94% reduction in data egress is not just a performance metric; it's a financial gain. Furthermore, the 57% reduction in total job time allows us to run more aggressive linting and unit testing suites without worrying about exceeding our free-tier CI minutes.

Pro-Tips for CI/CD Stability

Implementing caching is not a "set it and forget it" task. Over time, your cache can become stale or bloated, leading to unexpected conflicts. Here is how we maintain stability in our agritech apps:

  1. The Global Cache Purge: Every three months, we manually invalidate our caches by appending a version string to our keys (e.g., pub-v2-${{ hashFiles(...) }}). This forces a fresh fetch and clears out orphaned packages that are no longer referenced by our lock files.

  2. Isolate Test Dependencies: If your build job is strictly for production artifacts, consider using flutter pub get --offline if the cache is confirmed to be populated. This forces the Flutter CLI to strictly use the cache and fails immediately if a dependency is missing, rather than attempting to reach out to the network.

  3. OS-Specific Keys: Never share cache keys across operating systems. A Linux-compiled binary or a Dart package with platform-specific code (e.g., FFI) will break if restored onto a macOS runner. Always include ${{ runner.os }} in your cache key prefix.

  4. Handle Failure Gracefully: If you are building a complex CI chain, ensure that your "Post-cache" action handles potential permission issues. We occasionally see issues on self-hosted runners where the runner user doesn't have write permissions to ~/.pub-cache. Ensure your CI runner environment configuration reflects your user permissions.

  5. Monorepo Strategy: If you operate a monorepo, do not use a single pubspec.lock hash. Instead, concatenate the hashes of all lock files across your workspace. This ensures that a change in a small, isolated micro-service doesn't unnecessarily invalidate the cache for your main application packages.

Troubleshooting Common CI Cache Failures

Despite the gains, caching can occasionally lead to "phantom bugs" where a package is stored in the cache but is somehow corrupted. If you encounter strange pub errors, the first step should be to delete the cache key manually in your CI provider dashboard. This forces a clean rebuild. Once the clean build succeeds, the cache will be repopulated with a clean state.

Another common failure point is the pub-cache path itself on different CI providers. While GitHub Actions uses ~/.pub-cache consistently, other providers might default to project-local .dart_tool folders. Always run flutter pub cache dir on your CI environment to verify the exact path before defining your cache mapping.

Conclusion: Efficiency as a Competitive Advantage

Building for 50,000 farmers in Nigeria has taught us that complexity is the enemy of reliability. When we first started, our CI pipeline was a black box of inefficiency that hindered our ability to iterate. By treating our CI dependencies as a cache-managed asset rather than a transient download, we have successfully regained control over our build lifecycle.

This strategy is not just about saving seconds; it's about enabling a culture of continuous deployment. When a build takes six minutes, developers are less likely to run the full test suite. When it takes less than two, running the full suite becomes a standard part of every PR.

As you implement these techniques in your own Flutter projects, remember that the goal is to minimize the "Time to Feedback." Every second saved in CI is a second that can be better spent on improving the product, refining our price aggregation algorithms, or ensuring that our farmers get the information they need without lag. Start with your pubspec.lock, implement the granular cache, and measure the difference. You will find that the resources you save are not just bandwidth, but the mental energy of your entire engineering team.

Comments

No comments yet. Be the first!

Sign in to leave a comment.