iOS Mac Mini POC
Objective
Evaluate the capacity of a self-hosted Mac Mini for running iOS simulators and GitHub Actions runners for E2E testing, with the goal of running multiple test suites in parallel to reduce PR feedback time.
Simulator capacity on the current machine
iOS simulators are fundamentally different from Android emulators — they run native ARM code directly on the host CPU with no virtualisation layer (no QEMU, no KVM). There's no guest OS kernel, no software GPU rendering, and no separate ADB server. Compared to Android:
- Minimal idle CPU — only spikes during active test interaction (vs Android's constant QEMU overhead)
- No software rendering tax — uses the host GPU directly via Metal
Actual iOS simulator capacity on the current Mac Mini (10 cores, 32GB):
| Runners | Avg RAM | Viability |
|---|---|---|
| 3 | 85% | Passes but slower — same memory pressure as 4 runners, 4-6 min longer |
| 4 | 80% | Optimal — all suites pass, 13 min, stable |
| 5 | — | Not viable — OOM kills during startup |
Each runner slot carries a simulator, Appium server, and WDA instance. The combined overhead of 4 runners pushes the machine to 99% during startup; this drops quickly once simulators are booted and settled.
The same machine that can't sustain 3 Android emulators long-term can run 4 iOS runners — though not without memory pressure at startup.
What Was Tried
Memory consistently hits 99% during simulator launch regardless of runner count — the combined overhead of simulators, Appium, and WDA processes saturates 32GB at startup. The key variable is whether the machine recovers and stays stable once the sims are booted.
| Metric | 5 runners | 3 runners (unoptimised) | 4 runners (optimised) |
|---|---|---|---|
| Suites passed | 3/5 | 5/5 | 5/5 |
| Avg memory | 80% | 85% | 80% |
| Avg CPU | 62% | 56% | 52% |
| Pageouts | 66k | 23k | 15k |
| OOM kills | 2 | 0 | 0 |
| Test duration | ~12 min | ~16 min | ~13 min |
5 runners — 2 simulators OOM-killed during build download. Not viable on 32GB.
4 runners — all 5 suites passed in 13 min.
3 runners — was showing same memory behaviour as 4 runners (before optimisation). Fewer runners just means fewer parallel jobs, so tests take longer without meaningfully reducing the startup memory spike. Since we were optimising for the most runners as possible, I did not take the time to optimised further since 4 seemed feasible.
Observed Resource Usage
Final config (Run #10)
| Metric | Value | Notes |
|---|---|---|
| Test duration | ~13 min | |
| Peak memory | 99% (31G/31G) | ~2 min only, during startup |
| Avg memory | 80% | Stable throughout run |
| Avg CPU | 52% | No WDA compilation overhead |
| Pageouts | 15k | |
| OOM kills | 0 | All 4 simulators stable throughout |
| WDA peak CPU | 16.6% | Fully cached — no compilation |
| WDA processes | 4 | No duplicates (WDA kill working) |
| Post-run memory | 40% (13G/32G) | Clean recovery |
Full regression (21 suites, 2026-03-21)
21 suites run across 7 waves of 4 parallel runners. All 21 suites passed in ~83 min.
| Metric | Value |
|---|---|
| Avg memory | 78% |
| Peak memory | 99% (7% of the time — mostly wave transitions) |
| Avg CPU | 58% |
| Total pageouts | ~90k (~13k per wave, consistent with single-run rate) |
| OOM kills | 0 |
| Post-run memory | 35% (11G/31G) — lowest post-run baseline observed |
The machine sustained 7 consecutive waves without degradation. Resource usage per wave was consistent with single-run numbers, confirming the config is stable under extended load.
Limitations Discovered
99% memory peak is unavoidable on 32GB
4 simulators + Appium + WDA + runner processes exceed 32GB during startup. The 99% peak lasts ~2 min in the optimised config and then drops — it's not a failure state, but there's no headroom for additional processes during that window. 64GB RAM could potentially eliminate the peak and support 5-6 runners.
5 runners causes immediate OOM kills
5 concurrent simulators push the machine past its memory limit during the build download and extract phase. Simulators are OOM-killed before tests can start. Not viable on 32GB.
WDA derived data paths must not be shared
Sharing a single derived data path across runners causes WDA to rebuild from source for each simulator (different UDIDs/architectures invalidate the cache). CPU spikes return to 87%+. Each runner must have its own derived data path.
WDA duplicate processes accumulate without intervention
Without a kill step, each run can accumulate WDA process duplicates (8 observed vs expected 4), consuming ~1.5GB extra RAM at peak. A pkill -f WebDriverAgent before each run is required to keep the count clean.
Things to Try
- Smaller simulator device — switching to a smaller iPhone model (e.g. iPhone SE) could reduce the memory footprint per simulator and lower the startup spike. Not tried — the E2E tests are written against a specific device size and would need updating first.
Maintenance
- EAS build cache is cleaned up nightly at 3 AM — files older than 30 days are removed automatically.
- Runner logs are stored on the Desktop and retained for 30 days.
Final Recommendation
4 parallel iOS runners on the Mac Mini M4 (32GB), with the following optimisations — all are required for stable operation:
| Optimisation | What it solved |
|---|---|
| Build download caching | Eliminated redundant downloads; prevented simultaneous extract from exhausting memory |
| Staggered simulator boots | Smoothed the memory spike at startup; 4th sim boots ~1 min after first 3 |
usePrebuiltWDA | Eliminated 84-89% CPU compilation spikes |
| Per-simulator derived data | Keeps WDA cache valid per sim — sharing paths breaks the cache |
| WDA kill before start | Prevents duplicate WDA processes accumulating; keeps count at 4 instead of 8 |