Back to Blogs
#flutter#firebase#webdev#devops

Shipping Flutter Web to Firebase When Your GitHub Actions Budget Hits Zero

On October 18, right in the middle of shipping a multi-feature audit and a supplement tracker...

Updated 6 min read
Cover for Shipping Flutter Web to Firebase When Your GitHub Actions Budget Hits Zero

On October 18, right in the middle of shipping a multi-feature audit and a supplement tracker redesign on my personal life_os_app (my daily Flutter Web + Cloud Firestore operating board), GitHub Actions stopped scheduling jobs. The billing meter for my personal account's hosted runner minutes had hit 100%.

Look at what my .github/workflows/ci.yml and deploy-live.yml were doing on every push and pull request:

  1. Spinning up an ubuntu-latest VM.
  2. Pulling the Flutter SDK and warming the Pub cache (~90s).
  3. Installing Python 3 and running 68 CLI sync unit tests (~15s).
  4. Running flutter analyze and 466 widget and accessibility tests (~110s).
  5. Compiling flutter build web --release --no-web-resources-cdn (~140s).
  6. Authenticating via Workload Identity Federation (google-github-actions/auth@v2) and deploying to Firebase Hosting target board (~35s).

That is 6.5 minutes per PR push and another 4 minutes on merge to main. Across 70+ PRs in October, a single-developer personal project burned through thousands of CI minutes doing work my local Apple Silicon MacBook finishes in under 30 seconds.

When your CI budget hits zero on a personal repo, the tempting fix is to move everything to your laptop, deploys included. I did exactly that when the budget ran out, and it taught me where the line belongs: verification moves to the laptop, the deploy stays in CI, and CI runs only when I ask it to.

Here is the exact architecture I use to keep life_os_app shipping to Firebase Hosting and Cloud Firestore while spending Actions minutes only on the builds that actually go live.

Architecture: The Three-Layer Local Verification Gate

Taking verification off the runners only works if nothing unverified ever reaches a deploy. In life_os_app, the application state is split across two runtimes:

  • Python 3.14 CLI Sync Tools (tool/board_*.py): Morning and evening scripts that pull from Apple Reminders, Google Calendar, Harvest, Notion, and GCP Billing, then write structured JSON documents into Cloud Firestore over REST.
  • Flutter Web Client (lib/): A Riverpod 3 + Material 3 multi-skin web app served from Firebase Hosting with a custom offline Service Worker (web/offline-sw.js).

Layer 1: Firestore Schema Validation Before Every REST Write

One of the biggest risks of running CLI sync tools outside a container is accidentally clobbering fields that the Flutter Web client writes directly from the browser. For example, on the Events and Admin tabs, I tick tasks directly in the UI (config/events.taskTicks and config/admin.done), while tool/board_events.py and tool/board_admin.py sync metadata from Notion and GCP Billing.

To prevent a CLI run from wiping browser taps, tool/board_firestore.py enforces two rules before any HTTP PATCH leaves the machine:

  1. Explicit schema validation on every collection (dayPlans, stats, config, finance, plan).
  2. Field-level update masks (?updateMask.fieldPaths=...) plus hard blocks against overwriting client-owned map keys:
# tool/board_firestore.py
if path == "config/events" and "taskTicks" in data and not fields:
    raise SystemExit(
        "refusing to overwrite config/events.taskTicks without --fields; "
        "pass --fields updated,source,events"
    )
if path == "config/admin" and "done" in data and not fields:
    raise SystemExit(
        "refusing to overwrite config/admin.done without --fields"
    )
Enter fullscreen mode Exit fullscreen mode

In addition, caching the gcloud auth print-access-token call with @functools.lru_cache(maxsize=1) and enforcing a 15-second socket timeout cut multi-document Firestore sync latency by 80% and eliminated hanging background processes when switching Wi-Fi networks.

Layer 2: Per-Document Decode Isolation in Flutter Streams

In Cloud Firestore, a single malformed field inside one historical document in a collection query (collection('dayPlans').snapshots()) will normally throw a FormatException or TypeError inside .map(), killing the entire StreamProvider and replacing your UI with an error state.

During my audit pass, I hardened every repository to isolate decoding per document:

// lib/features/day_board/data/board_repository.dart
List<T> _decodeEach<T>(
  QuerySnapshot<JsonMap> query,
  T Function(JsonMap data, String id) decode,
) {
  final results = <T>[];
  for (final doc in query.docs) {
    try {
      results.add(decode(doc.data(), doc.id));
    } on Object catch (error, stackTrace) {
      developer.log(
        'Skipping malformed document ${doc.reference.path}',
        name: 'day_board.repository',
        error: error,
        stackTrace: stackTrace,
      );
    }
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

When saving multi-day edits (such as editing 7 days of focus blocks and habit notes in the This Week sheet), replacing 14 sequential await set() calls with a single atomic WriteBatch (saveWeekEditsBatch) eliminated partial-save race conditions on flaky mobile connections.

Layer 3: Self-Contained Web Bundles (--no-web-resources-cdn) & Service Worker Cleanup

When deploying Flutter Web to Firebase Hosting for daily use on iPhone, iPad, and desktop, CDN font/CanvasKit fetches can stall on airplane or train Wi-Fi. Building with --no-web-resources-cdn bundles CanvasKit and font assets directly into build/web/, paired with a custom web/offline-sw.js that races network against a 3.5-second timeout before falling back to cache:

// web/offline-sw.js
function networkFirst(request, timeoutMs) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
    fetch(request)
      .then((response) => {
        if (response && response.ok) {
          caches.open(CACHE).then((cache) => cache.put(request, response.clone()));
        }
        resolve(response);
      })
      .catch(reject)
      .finally(() => clearTimeout(timer));
  }).catch(() => caches.match(request));
}
Enter fullscreen mode Exit fullscreen mode

Notice the .finally(() => clearTimeout(timer)) cleanup: without it, every fast network response leaves an orphan timer running until timeoutMs expires.

The Deploy Stays in CI, on Demand

While Actions was out of minutes, I shipped with firebase deploy --only hosting:board straight from my Mac, every time after running the full gate locally. It worked, and it stayed an emergency exit, not the new normal.

The reason is one file. lib/firebase_options.dart is gitignored: the real web config lives in the FIREBASE_OPTIONS_DART repository secret, and the deploy workflows write it into the checkout right before flutter build web. A local build depends on whatever copy of that file happens to sit on your disk. If it is stale or points at the wrong app, the bundle still builds and deploys, and Firebase web initialisation breaks for everyone who opens the board.

So the rule is: the laptop proves the code is good, CI builds and ships it. What changed is when CI runs:

# .github/workflows/ci.yml: pull requests only, docs and handoffs never trigger it
on:
  pull_request:
    paths-ignore: ['handoffs/**', 'my_life/**', 'docs/**', 'reviews/**', 'archive/**', '*.md']

# .github/workflows/deploy-staging.yml: by hand, any branch, to the staging site
on:
  workflow_dispatch:
    inputs:
      ref: { description: Branch or commit to deploy, required: true, default: main }

# .github/workflows/deploy-live.yml: by hand, or a [deploy] marker in a commit on main
on:
  push:
    branches: [main]
  workflow_dispatch:
Enter fullscreen mode Exit fullscreen mode

Plus a ~/.pub-cache cache keyed on pubspec.lock, cancel-in-progress on CI so a new push kills the stale run, and the Firestore rules emulator job gated to changes in firestore.rules or tool/**.

The minutes math changes completely. Before: about 6.5 minutes on every PR push and 4 more on every merge. Now: the local gate runs on every change for free, and Actions spends one build-and-deploy when I decide a batch of work is ready, usually once in the evening after I have tried it on staging.

Benchmarking: Cloud CI vs. Local Verification Gate

Stage GitHub Actions (ubuntu-latest) Local Apple Silicon Gate Improvement
Python CLI Unit Tests (68 tests) 14.2s (with setup) 0.02s 710x faster
Dart Format + Analyze + 466 Flutter tests 118.0s 14.5s 8.1x faster
flutter build web --release --no-web-resources-cdn 142.0s 14.8s 9.6x faster
Total Wall-Clock Time ~4m 34s (plus SDK cache) ~29.3s ~9.4x faster, $0 CI cost

The deploy itself is not in the table on purpose: it runs in the workflow, once per release, not once per push.

Key Takeaways for Flutter & Firebase Engineers

  1. Keep CI YAML for PRs when budget allows, but make your local gate identical. A single local command (dart format -l 120 lib/ test/ && flutter analyze && python3 -m unittest discover -s tool -p '*_test.py' && flutter test) guarantees parity before you trigger a deploy.
  2. Verify locally, deploy from CI, and trigger deploys by hand. Secrets such as firebase_options.dart stay in one place, every live build is reproducible from a commit, and you pay runner minutes per release instead of per push.
  3. Use Firebase Hosting multi-site targets (hosting:board, hosting:staging). Mapping targets in .firebaserc lets you try a branch on staging, then promote to live, without touching sibling sites on the same GCP project.
  4. Never let one bad Firestore document take down a collection stream. Wrap per-document .fromJson() calls inside a helper that logs to dart:developer and skips the corrupted record.
  5. Test accessibility contrast and 44pt tap targets in flutter test, not in production. Running textContrastGuideline and androidTapTargetGuideline with GoogleFonts.config.allowRuntimeFetching = false catches neon text shadow contrast drops and 32pt icons in milliseconds.

The complete local gate in one command (the release build is in it, so a broken web build never reaches a workflow):

dart format -l 120 lib/ test/ && \
  flutter analyze && \
  python3 -m unittest discover -s tool -p '*_test.py' && \
  flutter test && \
  flutter build web --release --no-web-resources-cdn
Enter fullscreen mode Exit fullscreen mode

Then ship from CI, staging first:

gh workflow run "Deploy staging" -f ref=main
Enter fullscreen mode Exit fullscreen mode

And once it looks right on your phone, live:

gh workflow run "Deploy live"
Enter fullscreen mode Exit fullscreen mode