Offer

Why Does Flutter iOS CI Slow Down on a Cloud Mac? Three-Layer Cache Invalidation Is the Root Cause

CI Flutter · iOS build
2026-06-04 ~15 min read

One thesis: Flutter iOS CI is a cache-invalidation propagation problem, not three unrelated systems slowing down. The primary tension is CocoaPods (network + resolve, often ~70% of the ceiling), then Xcode DerivedData cache; Flutter toolchain cache is usually edge optimization. After §0 and §0.1 you know which layer to fix first on a rented Mac or GitHub Actions macOS runner.

30-second read (by priority)

  1. Primary bottleneck: Pods (network + resolve) > DerivedData (compile increment) > Flutter cache (toolchain)—fix Pods before incremental compile.
  2. Single cause: not “all three layers are slow,” but upstream cache miss forces downstream redo (chain in §1).
  3. One rule (§0): bind caches to lockfiles / SDK versions, not dates.
  4. If you’re busy: read §0 + §0.1 + §4 CocoaPods; if hot builds stay slow, add §5.
  5. §7 Pipeline: restore order matters more than clever key strings.
Flutter iOS CI build and cache tuning on a cloud Mac
Cover is illustrative; benchmark numbers below list test conditions.

0. The one caching principle

Before CocoaPods, DerivedData, or PUB_CACHE, align on one architecture rule—every key in this article follows it:

CI cache is not “store files”—it binds build determinism.

  • pubspec.lock / Podfile.lock change → cache miss → expected
  • Xcode or Flutter SDK upgrade → cache miss → expected
  • Job timestamp, calendar date, random suffix → must not alone cause miss

Typical violation: every day feels like a cold start because you manage caches with “time” or “wipe everything,” not “version + lockfile.”

0.1 Real bottleneck order for Flutter iOS CI

Every section below obeys one judgment—layers are not equally important:

Time ceiling (optimize in this order):
  CocoaPods (network + resolve)  🔥🔥🔥  often ~70% of ceiling
        ↓
  DerivedData (compile increment) 🔥🔥    hot builds → ~6 min tier
        ↓
  Flutter cache (toolchain)       🔥      mainly first-run pub / engine

Verdict (quotable): Flutter iOS CI slowness is usually not three parallel problemsPods cap the ceiling first; when Pods miss, perfect DerivedData only spins downstream.

Reading path: 10 minutes → §0 + this section + §4; cold ~15 min but hot still slow → add §5; §6 for image bake and first pub get.

1. Single-cause model: how cache misses propagate to IPA

This article does not treat three independent subsystems—it tracks one propagation chain. When tuning, ask: is the break upstream or downstream?

If flutter build ipa logs stall on Running pod install..., use §0.1: check Pods cache and network before Xcode compile flags. Still segment timings: pub getpod installxcodebuild, each with wall time.

1.1 Propagation chain (start → end)

Read in causal order (matches §0.1 optimize-first):

  [start · primary]  CocoaPods miss / slow private specs
           │  Pod tree rebuild, Specs fetch
           ▼
  [propagate]        DerivedData forced cold (full xcodebuild)
           │
           ▼
  [end]              Slow IPA (often blamed on Flutter/Dart)

Reverse triggers too: pubspec.lock change → plugin change → pod install rerun → DerivedData invalidated. Restore order in the pipeline should still put Pub first, but debug time and budget follow §0.1: Pods, then DerivedData, then Flutter cache.

Quotable: ~90% of “Flutter CI is slow” is upstream chain failure, not the compiler. Clearing DerivedData while Pods already hit can still make xcodebuild feel cold—that’s propagation loss, not Dart.

Official references: Flutter iOS deployment · CocoaPods environment · Xcode Build Settings.

2. Benchmark: validating §0.1 weights (reference ranges)

The table proves Pods first, DerivedData second—not equal effort on three layers. Data from typical kvmboot tickets (mid-size Flutter app, ~40 Pods, Release IPA, cloud Mac M4); rebuild your baseline on the same commit.

Stage Weight cold hot Notes
No cache ~28 min ~28 min Full chain broken; hot ≈ cold
Pods + Specs cache only 🔥🔥🔥 ~19 min ~12 min Largest single-step drop (28→19); do this first
+ DerivedData cache 🔥🔥 ~11 min ~6 min Hot build key; visible after Pods hit
+ Flutter pub/engine cache 🔥 ~10 min ~5 min Edge gain; saves first pub get

If cold stays >20 min, don’t touch DerivedData—return to §4 for Pods/private specs. If cold ~11 min but hot >12 min, check §5 restore and DERIVED_DATA_PATH.

How to self-test: Add /usr/bin/time -l or Actions step summaries for four lines—pub get, pod install, xcodebuild (or the Xcode segment inside flutter build ipa), total wall time. When comparing “no cache / Pods only / Pods+DerivedData,” fix the same commit and FLUTTER_VERSION or benchmarks are incomparable.

3. Same host as dual agents: resource boundaries only

The companion article from 2026-06-03, Cloud Mac dual AI agent: Claude Code + Codex isolation (2026-06-03), solves two CLIs not corrupting one worktree. When you add overnight Flutter iOS CI on the same rented Mac, watch three boundaries (technical—lease terms omitted):

  • Separate cache roots: CI uses its own DERIVED_DATA_PATH, not the agent’s ~/Library/Developer/Xcode/DerivedData.
  • Separate scheduling: parallel xcodebuild and agent indexing can saturate swap—on 16GB, prefer “agents by day, CI at night.”
  • No cross edits: agents must not run pod update on CI’s ios/Pods.

4. CocoaPods CI optimization 🔥🔥🔥

Primary layer. Roughly 70% of Flutter iOS build slow sits in pod install (network + Specs resolve). This section is what moves you from ~28 min toward ~19 min—DerivedData cannot recover a miss here. Per §0: keys bind Podfile.lock + CocoaPods version.

4.1 Environment variables and persistent paths

export CP_HOME_DIR="${CP_HOME_DIR:-$HOME/.cocoapods}"
export COCOAPODS_PARALLEL_CODE_DOWNLOAD=true
# persist: ~/.cocoapods/repos + ~/Library/Caches/CocoaPods

Private specs / binary Pods: keep the GitHub Actions macOS runner or self-hosted cloud Mac in the same region as private Git; auth via .netrc or CI secrets—never in the Podfile. Hosted vs self-hosted private-repo strategy: Bitrise vs self-hosted cloud Mac runner decision guide.

4.2 Pods/ tree: cache vs commit

  • Common: cache ios/Pods, key = hashFiles('ios/Podfile.lock').
  • Compliance-heavy: commit Pods/, CI runs pod install --deployment only.

Avoid blind pod update—it drifts lockfiles, breaks §0 determinism, and caches always miss.

If flutter build ios calls pod implicitly, run explicitly first cd ios && pod install --verbose then flutter build ipa: clearer logs and no race on an unrestored Pods/ directory. For CocoaPods CI optimization, knowing network vs resolve beats another wrapper script.

5. Xcode DerivedData cache 🔥🔥

Propagation segment. After Pods hit, DerivedData decides whether hot builds reach ~6 min. Per §0: key binds Xcode + Flutter + lockfiles; never reuse across SDK jumps. If §4 is still slow, this is not the first lever.

export DERIVED_DATA_PATH="/var/ci/derived/flutter-${FLUTTER_VERSION}-xcode-${XCODE_VERSION}"
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner \
  -configuration Release -derivedDataPath "$DERIVED_DATA_PATH" \
  -destination 'generic/platform=iOS' build

Tiered cleanup—not rm -rf DerivedData/* every job: per-job clear /tmp; daily prune stale subdirs; weekly retire keys after major upgrades. Disk quota and inode: cloud Mac runner disk and inode governance.

6. Flutter toolchain cache 🔥

Edge layer. PUB_CACHE and SDK bin/cache mainly save first-run downloads; benchmarks usually show 1–2 min. pubspec.lock changes can trigger upstream pod recompute—that’s §1 propagation, not a standalone fault.

Do not prioritize restoring build/ios across jobs; iOS builds belong on bare macOS / cloud Mac (see Docker troubleshooting guide for division of labor.

7. GitHub Actions macOS runner: pipeline weighted by §0.1

Combine §4–§6 into runnable YAML. Restore order (Pub → Pods → DerivedData) serves the chain; debug effort on keys follows §0.1: Pods first, then DerivedData.

Order Cache block Paths Key factors (§0)
1pub 🔥PUB_CACHEpubspec.lock + Flutter SDK
2–3pods 🔥🔥🔥~/.cocoapods + ios/PodsPodfile.lock + CP version
4deriveddata 🔥🔥$DERIVED_DATA_PATHXcode + Flutter + lockfiles
# .github/workflows/flutter-ios.yml (structure; macos-latest or self-hosted cloud Mac)
jobs:
  build-ios:
    runs-on: macos-14   # or runs-on: [self-hosted, cloud-mac]
    steps:
      - uses: actions/checkout@v4

      # ── Restore (order: Pub → Pods Specs → Pods tree → DerivedData) ──
      - name: Restore Pub Cache
        uses: actions/cache/restore@v4
        with:
          path: ${{ env.PUB_CACHE }}
          key: pub-${{ hashFiles('pubspec.lock') }}-flutter-${{ env.FLUTTER_VERSION }}

      - name: Restore CocoaPods Specs
        uses: actions/cache/restore@v4
        with:
          path: |
            ~/.cocoapods
            ~/Library/Caches/CocoaPods
          key: pods-specs-${{ env.COCOAPODS_VERSION }}

      - name: Restore Pods Tree
        uses: actions/cache/restore@v4
        with:
          path: ios/Pods
          key: pods-tree-${{ hashFiles('ios/Podfile.lock') }}

      - name: Restore DerivedData
        uses: actions/cache/restore@v4
        with:
          path: ${{ env.DERIVED_DATA_PATH }}
          key: dd-${{ env.XCODE_VERSION }}-${{ env.FLUTTER_VERSION }}-${{ hashFiles('pubspec.lock', 'ios/Podfile.lock') }}

      # ── Build ──
      - name: Flutter Pub Get
        run: flutter pub get

      - name: Pod Install
        run: cd ios && pod install --verbose

      - name: Flutter Build IPA
        run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist

      # ── Save (mirror restore; save costly layers even on failure) ──
      - name: Save Pub Cache
        uses: actions/cache/save@v4
        if: always()
        with:
          path: ${{ env.PUB_CACHE }}
          key: pub-${{ hashFiles('pubspec.lock') }}-flutter-${{ env.FLUTTER_VERSION }}

      - name: Save Pods + DerivedData
        uses: actions/cache/save@v4
        if: always()
        with:
          path: |
            ~/.cocoapods
            ~/Library/Caches/CocoaPods
            ios/Pods
            ${{ env.DERIVED_DATA_PATH }}
          key: dd-${{ env.XCODE_VERSION }}-${{ env.FLUTTER_VERSION }}-${{ hashFiles('pubspec.lock', 'ios/Podfile.lock') }}

After build, signing chain: codesign / Notarization on cloud Mac — not repeated here.

Common pitfalls: pod install before Pods tree restore (restore is wasted); skip save on failure (DerivedData never warms). Use if: always() save on costly layers, symmetric to restore, to stabilize hot builds.

8. Disk and inode (engineering notes only)

Three cache layers can push a 256GB SSD past 60% in days—inode often tops out before capacity. Alert at 80% space / 85% inode for the CI user; large caches on separate mount points; monthly runners get Tier-1 sweeps. Release-sprint daily leases can discard the instance at lease end—see temporary build matrix for release sprints.

du -sh ~/Library/Developer/Xcode/DerivedData ~/.cocoapods "${PUB_CACHE:-$HOME/.pub-cache}" 2>/dev/null
df -h .; df -i .

9. Acceptance and triage (§0.1 order)

Step 1 (🔥🔥🔥): pod install <90s when lock unchanged; cold enters ~19 min tier. Step 2 (🔥🔥): same-commit hot build P95 ≥40% shorter than cold, target ~6 min. Step 3 (🔥): can first pub get be skipped. Do not skip step 1 to tune DerivedData.

Symptom Likely cause First action
Hot ≈ cold, 20+ min total DerivedData wiped each job / path not fixed Check -derivedDataPath and restore ran
Only pod phase slow Specs CDN / private auth CP_HOME_DIR, .netrc, region
Spurious link errors DerivedData reused across Xcode versions Add Xcode version to key; retire old dir
SSH feels stuck Disk full / inode exhausted df -h; df -i + disk governance tiers

10. FAQ (common search phrasing)

10.1 Why is Flutter iOS CI so slow on a cloud Mac / GitHub Actions?

Usually CocoaPods cache miss (network, private specs)—propagation forces DerivedData cold compiles. Follow §0.1: Pods, then DerivedData—don’t blame the Dart compiler first.

10.2 Flutter iOS build slow: CocoaPods or DerivedData first?

CocoaPods first. Benchmark: Pods cache alone drops ~28 min → ~19 min; DerivedData dominates hot builds only after Pods hit (~12 min → ~6 min).

10.3 Is caching Xcode DerivedData alone enough?

No. DerivedData is a propagation-segment fix; pod install can still own cold starts. See §0.1 and §2 row 🔥🔥🔥.

10.4 How to set Flutter cache on GitHub Actions macOS runner?

See §7: restore Pub → Pods → DerivedData; save with if: always(); keys bind lockfiles/SDK, not dates (§0).

10.5 How does this pair with dual agents on one host?

The 2026-06-03 dual-agent article covers worktree isolation; this article covers CI cache roots. Use separate DERIVED_DATA_PATH (§3).

11. Conclusion

Flutter iOS CI is not “fix Flutter, Pods, and Xcode separately”—it is one cache-invalidation chain capped by CocoaPods (🔥🔥🔥) → DerivedData (🔥🔥) → Flutter cache (🔥). Hit ~19 min with §4, stabilize hot builds with §5, finish with §6–§7. Remember §0.1: Pods set how fast you can go; DerivedData sets whether hot builds stay fast.

Validate this iOS CI cache strategy in 48 hours

If you already passed Cloud Mac dual AI agent: Claude Code + Codex isolation (2026-06-03) on the same rented Mac, add an overnight Flutter pipeline: day 1 cold baseline, day 2 enable four restore layers from §7, day 3 compare §2 benchmarks for ~6 min hot. If pod install stays slow, fix private-network path before upsizing.

SSH onboarding and M4 sizing: rent-a-Mac onboarding checklist · specs and lease terms · configuration plans