Flutter build_runner: Do You Really Need to Optimize?

David Martin

Why does build_runner take so long every time you make a small change? If your Flutter project has grown with multiple generators, you might have noticed build times taking forever. Long rebuilds break your flow, and watch mode often feels impractical.

The truth is, build_runner is powerful, but without optimization, it ends up running more than it needs to. Then you might want to look at ways to configure it for faster, more targeted builds.

Note: The code examples in this article are simplified for explanation and learning purposes. They are not production-ready and should be treated as a reference only.

What does build_runner do?

The build_runner is a tool that scans your Flutter project for annotations and runs the associated code generators, known as builders. Each builder takes care of a specific task that would otherwise involve repetitive boilerplate code.

Common ones you might use are json_serializable for JSON parsing, freezed for data classes, mockito for test mocks, and retrofit for API clients. Each adds useful automation, but they all come with a cost: build_runner will loop over every Dart file and apply each active generator.

For a small project with just a few files, you may not notice any slowdown, and optimizing build_runner won’t bring much benefit. But as your project grows and you add more generators, unnecessary scans start piling up, and build times can jump from seconds to minutes. That’s when optimization becomes worth considering.

free trial banner

Essential build_runner Commands

Before deciding whether you need to optimize, it helps to understand the commands that drive build_runner. These are the ones you’ll use most often:

dart run build_runner build --delete-conflicting-outputs

Runs all active builders once and generates the required files. The –delete-conflicting-outputs flag ensures old or conflicting outputs don’t block the build.

dart run build_runner watch --delete-conflicting-outputs

Keeps watching your project and regenerates code as files change. For smaller projects, this alone often provides a smooth enough workflow without further optimization.

dart run build_runner clean

Removes previously generated files and cached outputs. Use this if you run into persistent errors or outdated code after changing dependencies. For many projects, using watch and occasionally running clean is enough. 

Optimizing build_runner with build.yaml

If watch mode still feels slow, scope each generator to only the files that actually need it. You do this in a build.yaml placed at the project root. The idea is simple: instead of running every builder across the entire codebase, you tell build_runner exactly where each one should run.

Here’s a simple example: 

# build.yaml
targets:
  $default:
    builders:
      json_serializable|json_serializable:
        generate_for:
          - lib/models/**.dart
      mockito|mockBuilder:
        generate_for:
          - test/mocks/generate_mocks.dart

This configuration limits json_serializable to model files and confines mockito to a single aggregation file for mocks. Fewer files matched means fewer scans and faster builds. To get it right, follow the tips below:

  • Builder keys: Some builders use a qualified form like packageName|builderName. Others use a simple name. Check the package readme to confirm the exact key.
  • File patterns: Use glob patterns such as lib/models/**.dart, lib/**/_*.dart, or test/**.dart. A good folder and naming convention make targeting precise and future-proof.
  • enabled: Keep it true only where needed. If a generator is temporarily unused, disable it rather than letting it scan the tree.
  • Avoid duplication: If two builders can produce similar helpers, pick one. For example, set freezed.options.json: false if json_serializable handles your JSON.
  • Centralize mocks: Instead of spreading @GenerateMocks annotations across many tests, put them all into one test/mocks/generate_mocks.dart file so that mockito only runs once.

Avoid these common mistakes:

While dealing with build.yaml configurations, there are high chances of overlooking small details that cause build_runner to run inefficiently, so keep an eye out for issues like these:

  • Targeting broad paths like lib/**.dart that defeat the purpose of scoping. Start narrow and expand only if something is missed. 
  • Forgetting to update generate_for when moving files. If the code stops generating, verify that the file now matches the listed pattern. Remember, not all packages support generate_for scoping, so check the package docs if your changes don’t take effect
  • Mixing overlapping responsibilities between builders creates extra work and larger diffs.

Pro Tip: Cleaning once helps the watcher start from a known state. With proper scoping, watch mode becomes responsive, and you avoid unnecessary regeneration across unrelated parts of the project.

dart run build_runner clean
dart run build_runner watch --delete-conflicting-outputs

Wrapping Up

So, do you really need to optimize build_runner? For smaller projects, probably not. The defaults work fine when you only have a few models and a single generator. But once you’re juggling multiple packages and hundreds of files, the extra seconds (or minutes) wasted on each rebuild add up fast and slow down your workflow more than you realize.

One point often overlooked is that build_runner is not just about speed but about control. By scoping generators and tightening your configuration, you’re making the build system predictable. That predictability reduces surprise diffs, keeps CI pipelines consistent, and prevents unnecessary churn in version control.