Canvas pinch zoom lag in a Capacitor WebView
Pinch-zoom on my game's canvas ran at 3 fps on an Android emulator. A canvas size budget, baked shadows and dirty patches took it to 60.
Contents
Find the Brainrot is a hidden-object game: a wooden table covered in stickers, and a strip of names to find on it. The table is bigger than the screen, so you pan it and pinch-zoom it. It is React 19 and a canvas 2D renderer inside a Capacitor 8 WebView, and the canvas sits inside a pan-and-zoom layer from a shared package of mine.
The board felt bad. Pinching in was the worst of it: the table froze, jumped, froze again. On an Android emulator, measured, pinch-in ran at 3–4 fps with single frames of up to 2.5 seconds. After one afternoon's work it ran at 58–60.
Every number in this post comes from the emulator. I have not measured it on a real phone, and nothing here was run on iOS. An emulator is a noisy instrument, and a good part of this post is about how noisy.
Measuring it before touching it
I wanted a number before any change, so the result would be a number and not an impression.
The WebView in a debug build exposes a DevTools socket named after the app's process ID, and
adb can forward it to a local port:
PID=$(adb shell pidof com.example.game)
adb forward tcp:9333 localabstract:webview_devtools_remote_$PID
curl -s localhost:9333/json # one entry per WebView in the app
That list had ten entries. Nine were about:blank pages belonging to the ads SDK. The game is
the one whose URL starts with https://localhost, which is what Capacitor serves the app from
with androidScheme: "https".
Over that socket, a small Node script (written with Claude Code) drove the board with synthetic touches and logged a timestamp for every animation frame. Trimmed:
const touch = (type, pts) => send('Input.dispatchTouchEvent', {
type, touchPoints: pts.map(([x, y], id) => ({ x, y, id })),
});
async function pinch(from, to, steps = 40) {
const at = (d) => [[cx - d / 2, cy], [cx + d / 2, cy]];
await touch('touchStart', at(from));
for (let i = 1; i <= steps; i++) {
await touch('touchMove', at(from + ((to - from) * i) / steps));
await sleep(16);
}
await touch('touchEnd', []);
}
// In the page: one timestamp per frame until told to stop.
await evaluate(`window.__fr = []; window.__on = true;
(function f(t) { __fr.push(t); if (__on) requestAnimationFrame(f); })(performance.now())`);
await pinch(60, 260);
const frames = await evaluate('window.__on = false; window.__fr');
adb shell input can only do one finger, so a pinch has to go through
Input.dispatchTouchEvent. The gaps between frame timestamps give fps, a median, a 95th
percentile and the worst frame. Performance.getMetrics before and after each step gives how
much of the time the main thread was busy.
The script ran the same sequence every time: three seconds idle, pinch in, pan while zoomed, pinch out, pan, and three seconds with the hint glow on. For the comparison I rebuilt the old version the same way as the new one, in the same build mode, and ran each build twice.
Before and after
| On the emulator | Before | After |
|---|---|---|
| Idle board | 11–12 fps | 58–60 fps |
| Pinch in | 3–4 fps, worst frames up to 2.5 s | 58–60 fps |
| Pinch out | 10–11 fps, worst frames up to 1.3 s | 60 fps |
| Pan while zoomed | 49–52 fps | 60 fps |
| Hint glowing | 12 fps (one run) | 54–60 fps |
| Canvas while zoomed in | 2772×3690 px | 1296×2131 px |
I made the changes together and did not measure them one at a time, so I can't tell you which was worth how much. One midway run, after the first round of renderer changes and before the rest, had pinch-in at 43 fps.
What was slow
Four things, found by reading the renderer with the numbers above in hand.
The canvas was sized for the whole zoomed table. It lived inside the zoom layer, and its backing store was the table's size times the zoom times the screen's pixel density. Zoomed in on the emulator, that came to 2772×3690, about 10.2 million pixels. Every pinch frame changed the zoom, so every pinch frame resized the canvas and drew every sticker into it again. iOS Safari's canvas limit is usually given as about 16.7 million pixels. The emulator draws two device pixels per CSS pixel; at three, the same zoom works out to about 23 million. That is arithmetic. I have not tried it on an iPhone.
Every sticker's shadow was blurred on every redraw. Each sticker was drawn with
shadowBlur set, so each full redraw ran one blur per sticker. A level on this emulator had
between 91 and 209 stickers.
Small effects redrew the whole table. The hint glow, the shake on a wrong pick, the pop on a find, picking a sticker up and putting it down: each one marked the entire table dirty. The glow pulses, so while a hint was showing the whole table was redrawn on every frame.
The game screen re-rendered four times a second while nothing happened. A play-time counter writes to the store every 250 ms, and the screen component was subscribed to the whole store.
The fixes
A size budget for the canvas
The canvas now covers the viewport plus a margin, not the table. Its resolution never goes above what the art has: stickers are 256 px images, so drawing one at 500 device pixels adds memory and nothing a player can see.
Panning slides the pixels already drawn and draws only the strip that came into view. During a pinch the existing pixels are scaled, and the canvas redraws sharp once the fingers stop. Zoomed all the way in, the canvas went from 2772×3690 to 1296×2131, and screenshots at full zoom after the redraw showed the same sharpness and the same shadows as before.
Bake the blur once
A sticker's shadow doesn't change, so it is blurred once into a cell of a shared atlas canvas and copied from there on every frame after. A simplified version of the bake:
// Simplified. Draw the art one cell to the side, clipped away,
// and cast its shadow back into the cell: only the shadow lands.
function bakeShadow(atlas, cell, img, blur) {
const g = atlas.getContext('2d');
g.save();
g.beginPath();
g.rect(cell.x, cell.y, cell.w, cell.h);
g.clip();
g.shadowColor = 'rgba(0, 0, 0, 0.35)';
g.shadowBlur = blur;
g.shadowOffsetX = -cell.w;
g.drawImage(img, cell.x + cell.w + blur, cell.y + blur,
cell.w - 2 * blur, cell.h - 2 * blur);
g.restore();
}
The cell next door has to be on the atlas too. That detail cost a round trip, described below. The glow got the same treatment: one baked sprite, drawn under the sticker while it pulses.
Redraw only what changed
A moving, glowing or shaking sticker now marks only its own rectangle dirty, and the frame redraws that patch and nothing else. The renderer's tests check it: a resting table draws nothing, and a glowing sticker redraws only its own patch.
Subscribe to what you draw
The store is zustand 5. The fix is one selector per value the screen actually shows, so a write to the play-time counter no longer reaches it:
// Simplified. Before: any write to the store re-renders the screen.
const session = useGameState((s) => s.session);
// After: the screen re-renders when something it draws changes.
const found = useGameState((s) => s.session.state.found);
const missed = useGameState((s) => s.session.state.missed);
The strip of names had a related habit: it measured itself on every render. It now measures when the list of names changes.
All 927 game tests and the renderer's 38 tests passed afterwards.
Four times the numbers lied
Frame rates above 60. The first baseline showed one step at 87 fps, and a later run had another at 91. The emulator refreshes at 60. My frame logger turned out to be counting frames twice: a step's logging loop was still alive when the next step started its own, and both pushed timestamps into the list. A median frame time of 0 ms gave it away. Any fps above the refresh rate is a bug in the measurement.
The glow row measured an ad. A late run showed the glow at 41 fps. The screenshot showed a rewarded video: I had used up the hints, so the hint button had started a test ad, and the row was timing that. With a hint actually glowing, the same steps ran at 54–60 fps.
The emulator was out of memory. Some runs fell to 3–8 fps on every step and kept falling,
which pointed at the device more than the board. The emulator had 2 GB of RAM, 155 MB of it
free and 900 MB swapped; the WebView process alone held 351 MB. Runs straight after a test video were the worst, and at one
point adb itself stopped answering. I threw those runs away and measured again on a fresh
launch. The page's own JavaScript heap was 33 MB. I wrote more about getting an emulator
into a state worth measuring in
how to make the Android emulator faster.
The baked shadows were empty. The fast numbers above were real, but for a while they were the numbers of a board with no shadows. My first bake drew the art far outside the canvas, so only the shadow would land on it. In the emulator's WebView, art drawn wholly outside the canvas produced nothing: 0 pixels of shadow, with or without a clip. Drawn partly outside, or inside the canvas and clipped away, a test image cast 6,698 pixels, exactly as many as a plain draw with a shadow. So the art now goes in the next cell of the atlas, which is on the canvas. A side-by-side crop of the old and new builds showed the shadows back, and runs with the shadows working held 58–60 fps. I only tested this in the WebView on the emulator.
The other 2 fps: a sticker nobody put down
Earlier the same day the board had a different frame-rate problem, and it had nothing to do with drawing.
The table had just been made bigger than the screen so it could pan without zooming first. At
1.5 screens wide there were only 90 px of extra table on each side, so I made it 2.5. Panning
still felt broken, and my own tests said it was fine: I had swiped with adb shell input swipe, slowly. I asked a second Claude Code agent to look at the board with fresh eyes. It
swiped fast, and found this.
The sticker table and the pan layer both listen to the same finger, and both call
setPointerCapture on it. A quick drag that started on a sticker moved far enough to make the
table pick the sticker up, and far enough to make the pan layer start a drag. A pointer has one
capture target, so from then on its pointerup went to the pan layer. The table never heard
the finger lift. The sticker stayed in a hand that no longer existed, and the table redrew
every frame for as long as it was held, which was the rest of the level. I reproduced it on
the 2.5× table: 62 fps before, 2 fps a few fast swipes later.
The fix is an event the table was not listening for. The element that loses a capture gets
lostpointercapture, and that is the moment to end the press:
// Simplified. The table's own surface hears this when the pan layer
// takes the finger: the press is over, and a held sticker is put down.
surface.addEventListener('lostpointercapture', () => {
if (press?.moved) release();
press = null;
});
Two smaller rules went in with it. A flick, or a press the system cancels, no longer counts as a tap, so it no longer costs the player a mistake. And a press that ever had a second finger on the screen never picks a sticker, so a two-finger tap or an abandoned pinch is free. That build also stopped one-finger drags from moving stickers at all, and with all of it in, the same fast and slow swipes in every direction panned the table at 61 fps.
That left the question of what one finger should do. I built four switchable schemes and tried them on the emulator. The one I kept: one finger slides stickers, two fingers pan and zoom. The stickers shove each other when you slide one, and that is part of the search, so giving it the single finger made sense. Checked on the emulator:
- A one-finger drag on a sticker moved it by (70, 40) and the table stayed put.
- A two-finger drag panned the table 114 px.
- Sliding a sticker and then adding a second finger put the sticker down, and the pinch took over without a jump: zoom 0.6 to 1.0 for fingers spread 1.67 times.
All the two-finger cases went through Input.dispatchTouchEvent, for the same reason as the
pinch bench.
What is still open
The first zoom-out on a freshly opened level can still produce a few frames of about 70 ms on the emulator. Warm runs don't show it, and I haven't pinned down the cause.
The rest is untested: a real Android phone, and iOS at all. I would trust these numbers as a direction until I have measured on a device.
What I would do first next time
Measure with a script before changing anything, run every build twice, and distrust any fps above the refresh rate. Then look at the canvas's backing-store size while zoomed in. Mine held more than ten times the screen's pixels, and nothing in the code made that obvious until a number did.