One perf bug is three bugs: a remounting SVG, un-memoized cells, and 2 MB sprite decodes on iPhone 11

Why [JellySplit](https://jellysplit.com) stuttered on iPhone 11 and not on newer devices or the simulator. A React key that accidentally depended on every move, an unwrapped component that threw away its native sprite view on every swap, and a 2 MB per-sprite decoded-bitmap footprint that started evicting the cache under sustained play. Three fixes stacked — none alone was enough.

JellySplit runs on a 4 GB iPhone 11 as the floor device. For a week, it ran badly: every swap dropped a handful of frames on-device while the simulator stayed buttery smooth. The fix wasn’t one bug. It was three wearing a trenchcoat, and any one of them alone would still have cost us frames.

The pattern is worth writing down because it’s the shape that performance bugs on low-RAM devices almost always take: the first fix gets you most of the way there, the stutter retreats but doesn’t disappear, and the temptation is to declare victory. That’s the worst place to stop. Mostly-fine ships.

Why iPhone 11 was the only place the bug surfaced

iPhone 11 ships an A13 Bionic and 4 GB of RAM. The chip is fast enough for basically anything a 2D puzzle game asks of it. The 4 GB is tight. When a React Native app’s decoded bitmap cache grows past a threshold iOS is keeping private, the OS starts evicting bitmaps to reclaim memory. A later render that needs an evicted image has to re-decode the PNG — on the main thread, mid-frame.

On a 6 GB phone (iPhone 12 and up) the cache never evicts and this failure mode is invisible. On the simulator, the host Mac has 16+ GB and the behavior doesn’t exist. The simulator isn’t lying to you. It’s just that the bug only lives in a regime the simulator can’t reach. If your floor device has less RAM than every other device you test on, you need to test on it. Nothing else substitutes.

Fix 1 — An SVG key that depended on every swap

The first diagnostic was an onLoad counter on the sprite component, logged to the console. A swap isn’t supposed to retrigger onLoad — the sprite is already decoded, React should just be updating its href. Every single swap retriggered onLoad on every visible sprite. Not re-rendering. Fully remounting.

The culprit was a week-old fix of mine. To solve a different sprite-caching edge case, I’d keyed the root <Svg> element on a boardSnapshotKey — a serialized fingerprint of every cell on the board:

// Before — every swap changes cells, so every swap changes the key,
// so React unmounts and rebuilds the whole SVG subtree.
const svgKey = `${boardIdentity}:${boardSnapshotKey}:${colorMappingKey}`;

The intent was to force a clean remount when puzzles changed. The reality is that every swap changes the board, so every swap produced a new key, and React dutifully unmounted and rebuilt the whole subtree. The fix in src/components/SwapHexBoard/SwapHexBoard.tsx is almost embarrassingly small:

// After — the two things that should actually trigger a remount:
// puzzle identity, and the color palette.
const svgKey = `${boardIdentity ?? 'board'}:${colorMappingKey}`;

A double-requestAnimationFrame “verification” effect that flashed the board to opacity: 0 during reconciliation went with it. The spinner went with it. The opacity flash went with it.

This fix alone recovered most of the frame budget. If I’d stopped here, iPhone 11 would have felt “mostly fine.” The board no longer tore down on every swap. But the onLoad trace now showed a smaller, quieter leak: the two swapped cells were still decoding their sprites on every move.

Fix 2 — React.memo with a custom comparator, and a key on position only

With the SVG no longer remounting, why were individual cells still losing their bitmaps? Two compounding problems in src/components/HexBoard/HexCell.tsx:

  1. HexCell wasn’t wrapped in React.memo, so every parent re-render ran its body.
  2. Every cell was keyed by a spriteKey that combined position, color, and cell state. When a swap changed a cell’s color, the key changed, and React threw away the old HexCell instance to build a new one. Throwing away the HexCell threw away its native AnimatedSvgImage view, which threw away its bitmap reference and demanded a fresh PNG decode of the “new” sprite — even though the same sprite was already cached on behalf of the cell it was being swapped with.

The fix is two changes:

// Key on position only. A cell at (q, r) is the same cell regardless of
// what color it currently holds. Position is identity; color is a prop.
{cells.map(({ q, r, ... }) => (
  <HexCell key={`${q},${r}`} color={...} ... />
))}
// Wrap in React.memo with an explicit comparator. Deliberately skip onPress,
// which SwapHexBoard passes as a fresh closure on every render but is
// semantically stable per (q, r).
const arePropsEqual = (prev: HexCellProps, next: HexCellProps): boolean =>
  prev.cx === next.cx &&
  prev.cy === next.cy &&
  prev.hexRadius === next.hexRadius &&
  prev.cellState === next.cellState &&
  prev.isDark === next.isDark &&
  prev.isHinted === next.isHinted &&
  prev.isSelected === next.isSelected &&
  prev.isDimmed === next.isDimmed &&
  renderInfoSignature(prev.renderInfo) === renderInfoSignature(next.renderInfo);

export const HexCell = React.memo(HexCellImpl, arePropsEqual);

Two things about that comparator are worth calling out:

  • onPress is intentionally not compared. On mobile it’s always undefined (PanResponder handles touches on the parent). On web, SwapHexBoard passes a fresh closure on every render — but that closure is semantically stable per (q, r) because q, r don’t move. Including onPress in the comparator would invalidate the memo on every render for no user-visible benefit.
  • renderInfoSignature is a shallow structural hash of the render object, not a deep equality check. Deep equality on every prop comparison would cost more than the memo saves.

With both changes in place, a swap passes new props to the existing HexCell instance. The native SvgImage updates its href without tearing down the view, and the sprite decode — already in cache — is reused. I added a regression test in SwapHexBoard.test.tsx that asserts the cell’s mount counter stays at 1 through a color change. It’s the kind of test that tells you nothing until the day someone types React.memo on a prop whose closure isn’t stable and ships it. Then it tells you everything.

Fix 3 — Shrink the sprites, because 2 MB × N is too much cache

At this point the hot path was clean. The SVG stayed mounted, cells stayed mounted, sprites weren’t re-decoding. On newer devices, the lag was gone. On iPhone 11, it was mostly gone — but the occasional swap still stuttered, especially after a few minutes of play.

That pattern — fine at first, bad under sustained play — is what memory pressure looks like. I pulled up the sprite dimensions. There were 458 board sprites in assets/jellies/, most of them at 766×702. Decoded into ARGB bitmaps, that’s:

766 × 702 × 4 bytes ≈ 2.05 MB per sprite.

iPhone 11’s decoded-bitmap cache had plenty of room to start, but over the course of a few minutes of play, different cells cycled through different color states, each of which hit a different sprite asset. Once the cache crossed the threshold iOS was willing to hold, it started evicting older sprites to make room. Evicted sprites became mid-swap re-decodes on the main thread. Stutter.

The visible hex on screen is tiny compared to a 766×702 source — even on a high-DPI phone, a hex cell in a 37-cell puzzle takes maybe 90 pixels across. The extra source resolution was buying nothing. In assets/sprinkle-jellies.html — the HTML page I use to generate the sprite sheet via a Three.js render — the 3D render still happens at 1024 for antialiasing quality, but the export step now downsamples the final PNG to a max dimension of BOARD_SPRITE_EXPORT_MAX_DIM = 384 before it hits disk.

The numbers:

  • Per-sprite decoded footprint. 766×702 (~2.05 MB decoded) → 384×352 (~0.54 MB decoded). A 75% reduction in bitmap cache pressure.
  • Asset bundle. assets/jellies/ dropped from roughly 103 MB to 46 MB on disk. App downloads got smaller as a free bonus.
  • Result on iPhone 11. The cache now fits. No more evictions, no more mid-swap re-decodes, no more stutter.

There’s a temptation to skip this fix because the visual difference is, at the pixel level the player sees, nothing. The sprites look identical. That’s the correct outcome: if your textures are bigger than the space they render into, you’re paying for pixels that end up in the downsampler, not on the screen.

The generalizable pattern

A perf bug isn’t always one bug. Any single one of these three fixes would’ve gotten me to “better.” None of them alone would’ve gotten me to 60 fps.

The pattern I keep coming back to:

  1. Fix the biggest one first. In this case, the SVG remount was the dominant cost. Everything else was noise until it was gone.
  2. Re-measure with the same diagnostic. The onLoad counter told me the SVG was fixed but the cells still weren’t. Without re-running the same trace, I’d have moved on thinking the bug was solved.
  3. Don’t stop at “mostly fine.” Memory-pressure bugs specifically hide behind one-off fixes — they retreat enough to feel better, but they’re still there, and they’ll get worse as the session gets longer. The test condition that matters is sustained play on the worst device, not a thirty-second smoke test.
  4. Test on the floor device, not the median. If you test on the same 6 GB phone you develop on, iPhone-11-class memory bugs don’t exist for you until a user files a bug report.

The player-facing take on this fix — just the “swap feels snappy now” version — lives at The iPhone 11 Swap Lag Hunt on the game’s blog. If you’re curious how the other plumbing works under the same app, there’s a companion post on solving every level at build time and one on shipping an ONNX CNN to a React Native Expo app — different subsystems, same floor device.