Kelven Galvão

How I got our Flutter CI from 21 minutes to under 7

Step timings pointed at 304 Flutter engines booting per run, caches the tag builds could never read, and a staging job that compiled the same Dart twice.

Our pull request checks took a median of 21 minutes over the last nine runs, and the slowest hit 27. Staging deploys took 34 to 36. At that speed people stop pushing small commits. They batch changes, start something else while the checks run, and come back twenty minutes later with no memory of what they were testing.

So I read the step timings first.

The coverage job was the critical path at about 20 minutes, and 16.5 of those went to a single command, flutter test --coverage. The Android build took around 17 minutes, with 12.7 of them spent inside Gradle. Two slow jobs. The first one was hiding most of the problem, and you can’t see that from a dashboard that just says the pipeline is slow.

304 test files, 304 engines

flutter test boots a separate Flutter engine for every test file. With --coverage on, it also collects and writes coverage once per file. We had 304 test files. The assertions were quick, and most of those 16.5 minutes went to booting engines and dumping coverage 304 times on a runner that only used two processes.

I wrote a small Dart script that generates a handful of bucket files for CI. Each bucket imports roughly a quarter of the real test files and calls their main() inside a group of its own. Simplified, one looks like this:

// test/ci_buckets/bucket_0_test.dart (generated, gitignored)
import '../unit/bloc/home/home_bloc_test.dart' as t0;
import '../unit/bloc/search/search_bloc_test.dart' as t1;
// ...about 76 more

void main() {
  group('test/unit/bloc/home/home_bloc_test.dart', () {
    t0.main();
    tearDownAll(GetIt.I.reset);
  });
  // ...
}

With four buckets and -j 4, Flutter boots four engines, writes coverage four times and keeps all four cores busy. Locally, on a 10-core machine, the 3,038 tests went from 6 minutes 18 seconds to 47 seconds.

We’d tried sharding across runners once before and reverted it. Bucketing stays inside one job, so there are no artifacts shipped between machines, no lcov files to merge, and no extra runner minutes on the bill.

The first bucketed run failed 163 tests.

Separate isolates had been hiding shared state for years, and once 78 files shared an isolate, every test that leaked something started tripping over its neighbours. All 163 failures traced back to three habits. Twenty-four files registered services into GetIt at the top level of main(), which runs while the bucket is still loading, so the registrations collided. Moving them into setUpAll fixed that, and every one of those files still runs fine on its own. Another file set debugDefaultTargetPlatformOverride inside a plain test() and never reset it. Every widget test after it failed a framework invariant check, so it resets in tearDown now.

The last one was a clock. The test compared against a timestamp taken when main() ran, which is harmless when main() runs a millisecond before the test and wrong when 77 other files load in between. It reads the clock inside the test now.

If you try bucketing, budget a day. Every one of those 163 failures was a real bug in a test, and none of them showed up while each file had an isolate to itself.

Caches nobody could read

We had no caching at all for pub packages, build_runner output, Gradle or CocoaPods, and code generation ran cold three times per pipeline. Adding the caches is mostly configuration. Getting them read depends on one GitHub rule: a cache is visible only to the ref that wrote it and to the default branch.

Our checks and deploys run on tags. A cache written by a tag run can’t be seen by any other tag, so all it does is eat into the 10 GB quota. Three duplicate SDK caches of 1.9 GB each were sitting there doing exactly that.

Caches now restore everywhere and save only from develop. That has a cost. The build jobs also run on every push to develop, even though nobody waits for that run, because it’s the run that writes what every tag build reads.

Codegen needed a different trick. Most branches never touch an annotated file, and checking that with a git diff gives the wrong answer once develop has moved since the branch was cut. The shared bootstrap action hashes every input that can change generated code: about 300 annotated sources, pubspec.lock, build.yaml, the Flutter version pin, .env and the ObjectBox model. That hash travels inside the cached .dart_tool/build. On restore the action recomputes it. A match means the generated files on disk are exactly what build_runner would write, so the step is skipped. Anything else gets an incremental build.

One generator broke the scheme. We use envied for environment config, and it reads .env through dart:io, so build_runner never learns that .env is an input. So env.g.dart stays out of the cache. It regenerates on every run, which costs about 10 seconds on a warm graph. Check which of your generators read files behind the build system’s back before you cache any of them.

Skipping builds the diff can’t affect

Every PR used to run both native builds. That included Dart-only PRs and even README-only ones.

A new first job asks GitHub’s compare API for the three-dot diff against the merge base with develop. It needs no checkout and finishes in seconds. From that diff it decides whether Android native inputs changed, whether iOS native inputs changed, and whether any code changed at all. A docs-only PR skips coverage and both builds and still goes green. Dart-only PRs skip the native builds.

This works because GitHub counts a skipped job as satisfying branch protection, so the required checks stayed required. A push to develop always runs everything anyway, as does a tag with full in its name or any diff touching .github/.

Gradle

Gradle was running cold and serial. These went into gradle.properties:

org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
android.enableJetifier=false
android.nonTransitiveRClass=true

Jetifier rewrites support-library references in every dependency on every build, and once nothing in your tree needs that, switching it off is free. nonTransitiveRClass stops each module compiling every other module’s resource IDs into its own R class.

Smaller things came out too. The wrapper had been downloading the -all Gradle distribution, sources and docs included, onto a runner that would never open them. It pulls -bin now. A dependency declared as firebase-messaging:+ got a pinned version, since a dynamic version means a network lookup on every cold runner and a build that can change with no code change. The Firebase Performance plugin stopped instrumenting debug builds, and an evaluationDependsOn(':app') that forced projects to configure in order was removed.

Compiling the same Dart twice

Staging built a release APK, which took 21.7 minutes, and then built an AAB from the same source. The AAB went to the Play Store and the APK went to Firebase. Now the workflow builds the AAB once and runs bundletool build-apks --mode=universal to derive the APK, signed with the same key and written to the path the later steps already read.

Ideas that didn’t pay

I expected a --debug --simulator iOS build to beat the release device build easily. Cold, it took 284 seconds. Release took between 269 and 285. The gate kept the release device build, since that’s closer to what users run.

The Gradle configuration cache went the same way. It only pays across runs if you give it an encryption-key secret, so I dropped it.

Where it landed

Warm, the pipeline takes 6.6 minutes. It used to take 21. It still runs every test and still reports coverage, though coverage now leaves out generated files (*.g.dart, *.freezed.dart and the DI config), so the number stepped once against the old baseline and now measures only code someone wrote by hand.