·10 min read

How to make Android emulator faster on a Mac

My Pixel 9 Pro emulator went from 3 fps to about 50 on a Mac with a cold boot, a smaller screen and Docker switched off. The same habits fixed most of my flaky device tests. One failure is still unexplained.

androidtestingcapacitorgamedev

Contents

I did not go looking for how to make Android emulator faster. I went looking for why my game was too hard.

Echo Bat has an autopilot: a bot that flies the cave with a human's reaction time and touch delay. I run it headless in Node and on an emulator. In one narrow tunnel the device pilot hit the ceiling twice within 3–6 seconds of take-off. Headless, the same pilot flew the same tunnel 20 times for ten minutes each and never went down. I was ready to blame the tunnel.

The emulator was in no state to judge it. Over the same week, an unhealthy emulator, or the Mac under it, was also behind most of my flaky device tests in two other games. This post is what I measured, what made it faster, and the one test failure I still cannot explain.

The setup: an Apple Silicon Mac with plenty of memory, Android Emulator 36.4.9, a Pixel 9 Pro AVD on the Android 16 (API 36) image with hw.ramSize = 2048 and the gfxstream graphics backend. The games are Capacitor apps, so the game is a canvas inside a WebView. The device tests are Appium with UiAutomator2. Several Claude Code sessions were working on the same Mac and sharing emulators, which matters later.

Measuring frame rate inside the WebView

Watching the emulator window tells you it feels slow. I wanted the number of frames my game actually got. A debuggable WebView exposes Chrome DevTools over a socket, so I forward it and count requestAnimationFrame callbacks for two seconds:

PID=$(adb shell pidof com.example.game)
adb forward tcp:9333 localabstract:webview_devtools_remote_$PID
import { chromium } from 'playwright-core';
const browser = await chromium.connectOverCDP('http://localhost:9333');
const page = browser.contexts().flatMap(c => c.pages())[0];
const frames = await page.evaluate(() => new Promise(done => {
  let n = 0;
  const start = performance.now();
  const tick = () => {
    n++;
    if (performance.now() - start < 2000) requestAnimationFrame(tick);
    else done(n);
  };
  requestAnimationFrame(tick);
}));
console.log('rAF frames in 2 s:', frames);

rAF frames in 2 s: 101 is about 50 fps. On the worst day it printed 4.

What made the Android emulator run faster

Three changes, each measured on its own, in the order I made them. All numbers come from the probe above, on the Echo Bat home screen.

State Frame rate
Emulator up 17 hours, about 1 GB swapped inside it 2–3 fps
After a cold boot 12–16 fps
Screen lowered to 720×1600 33 fps
Docker Desktop quit about 50 fps

A few days earlier the cold boot alone had been enough. The emulator had been up five days with 1.3 GB swapped out, and it ran at 5–42 fps with stalls of up to 4 seconds. After a cold boot: 60 fps, a median frame of 16.7 ms, the longest stall 83 ms, 4.3% of frames slow. That time the Mac itself had room. The second time it did not, which is why the table needs four rows.

1. Cold-boot it when the guest is swapping

Android in the emulator swaps to compressed memory, and after hours of installs and test runs it builds up. Check it from the host:

adb shell "cat /proc/meminfo | grep -E 'SwapTotal|SwapFree'"

Then restart it without the quick-boot snapshot, so the bloated state is not saved and brought back:

adb emu kill
emulator -avd Pixel_9_Pro -no-snapshot-load

Wait for the old process to exit first. Twice I launched the new one too early and it refused to start because the old one still held the AVD's lock.

The swap comes back. Across a day of test runs I saw 760 MB, 950 MB and 1 GB again. My test runner now prints a warning at the start of a run when the emulator has more than a few hundred MB swapped, because every step is several times slower in that state.

2. Draw fewer pixels

The Pixel 9 Pro profile is 1280×2856. My game draws every pixel of that every frame, and the emulator has to render all of them on the Mac. Lowering the screen size is two commands and needs no reboot:

adb shell wm size 720x1600
adb shell wm density 270

That is under a third of the pixels, and the frame rate went from 12 to 33. I took a screenshot to confirm the game still laid out correctly. Undo it with adb shell wm size reset and adb shell wm density reset, and put it back before any test that checks layout, since the game now sees a different screen.

3. Give the Mac its memory back

At 33 fps the emulator was using about 21% CPU. It was starved, not busy. The Mac's swap was almost full and kernel_task sat at 100%. The emulator process itself, configured with 2 GB, took about 7.2 GB on the Mac even straight after a cold boot, and that is its normal size.

The biggest thing I could close was Docker Desktop at 3.7 GB. Quitting the app was not enough: its backend processes kept running, and one ignored the stop request until kill -9. With Docker gone, the probe read 101 frames in 2 seconds, about 50 fps.

sysctl vm.swapusage   # the Mac
uptime                # load average

How fast swap drains after you close things varies. One time, stopping two simulators and two emulators took it from 34.8 GB to 27.6 GB within minutes. Another time I stopped an emulator, two simulators, an old xcodebuild run and a Gradle daemon, and it went from 39.7 GB to 37 GB and stayed there.

What does not make the Android emulator faster

Pre-decoding the game's images. I moved every sprite atlas into an ImageBitmap so nothing would decode mid-frame. Still about 3 fps. It also kept every atlas in memory on a machine already out of it, so I reverted it.

A cold boot, when the slow part is your own code. On another day the game ran at 8 fps during play, with about 1 GB swapped inside the emulator. Partway through, I cold-booted it, and the numbers did not move. Profiling showed canvas filters costing 110–190 ms per layer per frame. Removing them, and composing the cave at the art's own resolution, took play from 8 to 49–55 fps on the same emulator.

So the first question is whose time it is. On the 3 fps day the profile said my drawing took about 0.2 s out of every 3; the rest was the system. On the 8 fps day it was nearly all mine. Neither looks different from the outside.

The emulator was lying about difficulty

Back to the tunnel. I recorded four device runs of 150 seconds each before the cold boot:

Run Frames Longest stall Frames over 40 ms Score
1 3,867 (about 25 fps) 3.8 s 491 69
2 699 (about 5 fps) 4.0 s 646 18
3 3,934 (about 26 fps) 2.2 s 579 85
4 6,341 (about 42 fps) 2.0 s 236 122

At 60 fps a run scores about 200 in that time. The game slows its own clock through a stall so the bat does not teleport, but the autopilot taps on real time, so its taps land where it did not plan them.

I meant to show that the deaths lined up with the stalls. None of the four runs died, so I never did. What I have is the after: once the emulator was cold-booted, the device autopilot flew the same build of the tunnel for about 45 minutes and reached 4,830 points without dying, which is what the headless runs predicted for it (0 deaths in 20). I stopped trusting device scores from an emulator I had not checked first.

Four causes of flaky device tests

The same week, device tests for Picture Word Connect and ASMR Logo Craft Block failed at random steps. Four separate causes turned up.

A leftover Appium server. One was 3.5 hours old, attached to the QA emulator, with no test run owning it. The runner starts its own server per run, so I killed it. I cannot say how many failures it caused: the next run still failed once, for the next reason.

Late interstitial ads. In the failed run the settings popup vanished and the app opened level 1000. A full-screen ad arrived late on the slow emulator, and the runner's tap meant for the popup landed on Play behind it. The runner only closes ads between its own steps, so I made the game report itself busy until the result screen is up, and took the level solves that trigger the end-of-level ad out of that scenario. Two clean passes in a row after that.

Relaunch steps that always failed. Two scenarios failed at the same step, every time:

WebDriverError: 'com.example.game' is still running after 10000ms timeout
when running "execute/sync" with method "POST"

By hand, after am force-stop the app came back as a new process within 2 seconds. adb shell dumpsys activity processes explained it: under mPendingStarts the system held two stale start records for the app. Restarting the emulator the normal way, which on these AVDs resumes from the quick-boot snapshot, gave the same failure. A cold boot with -no-snapshot-load cleared it, and both scenarios passed.

The Mac running out of room. This one produced the widest spread of errors:

Error: Appium Settings app is not running after 30000ms
WebDriverError: The instrumentation process cannot be initialized within 30000ms timeout.

Another time a run went 1 passed, 7 failed because Android's own "System UI isn't responding" dialog covered the consent form. Two test runs were killed with exit 137 while swap was nearly full. Around these failures I measured load averages of 184, 398, 419 and once 645, and swap almost full. What drove the load was ordinary: a VS Code reload, mediaanalysisd at about 300% CPU, a tablet emulator left running for 15 hours at 156% CPU, another session's script at 1,100% CPU.

I now wait for the one-minute load average to drop under 30 before starting a run. That number is from this Mac: runs failed at 33–35 and passed once the load fell.

When a test fails on a change I suspect, I build the commit before it in a separate git worktree and run the same scenario on the same emulator. One banner test failed on the old build too. That emulator does not get a test banner, and the change was fine.

A faster Android emulator boot that kept the bug

Quick boot is why the emulator starts in seconds, and my AVDs all use it. I keep it. But the snapshot it resumes from also carried the stale process records above, and a plain restart looked like a fix while changing nothing. When a failure survives a restart, the next step is -no-snapshot-load, and I wait the extra minute or two of a cold boot.

The failure I cannot explain

On an 8-letter letter wheel, a device test traced HANGER and the game read "HANGR", as if the finger had picked up R on the straight glide from G to E. R does sit between them on a crowded wheel, but the wheel's spacing should prevent that. It happened once. Every rerun passed, and I never reproduced it, so I have no cause. If a player reports a letter grabbed by accident on an 8-letter wheel, that is where I will start.

Before I trust a device run

Load under 30 (uptime). Mac swap not full (sysctl vm.swapusage). Emulator swap near zero (/proc/meminfo), or a cold boot. No Appium server I did not start. Then the frame probe, if the test is about how the game plays.

It takes a minute, which is less than I spent reading device scores from an emulator that could not produce them.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.