On paper, an iPhone 16 Pro should never stutter rendering 192 circles with React Native Skia. But my screen was stuttering, and the FPS counter said 80
While testing the animation, I felt a small jitter. I ignored it because it was running in the simulator. Then I ran it on my iPhone 16 Pro, and there it was again: the stutter.
I ran it on both my iPhone 16 Pro and Samsung S24 side by side, and the S24 showed nothing or at least I didn't catch it, but the iPhone was consistently stuttering in the transition phase of the animation.
I got the inspiration from something I saw on Minsang Choi's X and Siri border glow.
Picture 192 balls: 128 on the border and 64 oscillating, moving like one gooey metaball blob, merging, and separating as the user interacts with the app.
So I thought of my stack:
- Expo, of course
- Skia RuntimeEffect shader
- Reanimated 4 worklets and SharedValues
First a quick metric guide as we're gonna repeat this all over the post
Metric What it means How to read it here FPS Average frames rendered per second Good first signal, but it can hide visible stutter Frame budget Time available to finish one frame 120Hz = 8.33ms,60Hz = 16.7msMissed frame % Share of frames that missed the target budget Better than average FPS for spotting stutter p50 / p90 / p99 Median / slower-end / worst-end timings Shows whether the problem affects most frames or only the tail Encoder time CPU time spent submitting work to Metal Useful, but it does not prove shader execution was cheap JS thread vs UI thread React Native JS work vs animation/worklet-side work JS can be idle while UI-thread worklets still cause frame drops Main rule: average FPS tells you speed; frame timing tells you rhythm.
The GPU Is the Obvious Suspect
It must be the GPU
So I started as always with the performance monitor.
The S24 averaged 110fps and dropped down to ~104fps on transition. However, the iPhone showed ~39fps on the UI counter, while the JS stayed at 60fps.
Hindsight:
The reading showed clean JS thread performance on iOS which should've hinted where the problem was.
As these results showed what I was experiencing, but nothing more, I needed another way to really find out what went wrong. I tried the inspector, but I couldn't get valuable GPU data from it.
I needed to go deeper.
That's when I looked into native profiling as this was the first time I had profiled the GPU beyond the performance monitor and custom logging code.
As I was mostly concerned with iOS, the tool for the job was Instruments. I had never used it before so I needed some help. Fortunately, the tool was simple to understand and it's well-integrated in the iOS ecosystem.
With a fresh release build, on device, I used the Metal System Trace template. I started recording for ~10 seconds with steady triggers. Here's the result:
- Encoder time was nearly empty. Microsecond submits, apart from one frame-1 spike (the shader compile).
- CPU usage was busy. Something is working hard every frame. But what?
I didn't understand much 😭 apart from some spikes with orange and some long operations. Two tracks stood out enough to extract data from; see the callouts above. The rest of the trace was noise to
me. (It wasn't. But that comes later.)
So instead of trusting the chart, I extracted data.
Encoder p50: 140 µs
Encoder p99: 621 µs
Encoder max: 15,790 µs ← only frame 1 (shader compile)
> 1ms: 1 (just the first frame)
Still 42% of frames missed the 16.7ms budget by ~1ms 👀.
(Yes, 16.7ms, not the 8.33ms from the metric guide. The animation was heavy enough that the display had already dropped out of its 120Hz cadence. Why that happens comes later.)
One caveat:
I could not get reliable shader execution timing from Instruments, so this did not fully clear the GPU. But the measured data was pointing away from Metal submit and toward something happening earlier in the frame.
The frame was late at ~17ms, but the Metal submit was a fraction of that at 0.14ms.
Now the question is, what was happening upstream before Skia submitted the frame?
The Journey Upstream
The GPU handoff is clean, who's eating the 17ms?
Testing the Upstream Theory
The encoder submit was tiny, so the cost wasn't at the CPU → GPU data handoff. The steady 17ms frame intervals did not look like a random GPU spike either.
That made the upstream theory worth exploring.
So I added the Time Profiler to check what was happening before Skia submitted the frame.
Adding Time Profiler
I hoped this would help answer the question of: which code is running during those 17ms?
Total CPU samples: 3,099 ms over 10 seconds
Main Thread: 2,234 ms (72.1%)
JavaScript thread: 18 ms (0.6%) ← idle
AGX / Metal driver: single-digit samples each
This shows 3 unambiguous things:
- Metal driver is barely doing any work.
- JS thread is idle.
- UI (Main Thread) (where the worklet lives) is doing most of the work.
In this setup the worklet runs on the UI runtime, which shows up as Main Thread work on iOS, and that's why the React Native JS thread is idle while the animation was doing heavy work.
And there it was: the worklet looked guilty. But could it be the calculations, or something else entirely?
The Worklet Was the Wall
What made the worklet slow? Was it the math or the way I wrote it
Now that I had narrowed down the symptom to the Main Thread, I dug in deeper and traced its call tree.
Two culprits surfaced from leaf functions, putComputedWithReceiver_RJS and malloc/free (_xzm_xzone_malloc_tiny). I had to search what they were as I was unfamiliar with Hermes lower-level functions.
In plain English, the profiler was pointing at two things:
- generic indexed writes:
buffer[i] = value- short-lived allocations: creating and discarding a fresh array every frame
putComputedWithReceiver_RJS: Hermes' bytecode handler for a computed property write, obj[key] = value where the key is computed, like an array index. It's the slow path because Hermes can't use a fixed object shape; it has to do a generic lookup per write.
In the code: the worklet's buffer[i] = x, buffer[i + 1] = y, … loops over 128 mostly static balls and 64 oscillating ones.
At 4 indexed writes per ball, that came to ~768 writes per frame at 192 balls. That loop is what lit this function up.
malloc/free (_xzm_xzone_malloc_tiny): the iOS allocator's fast path for tiny allocations.
Seeing it hot suggested is allocating short-lived objects that the GC deallocates right away.
In our code: the new Array(MAX_META_BALLS * 4).fill(0) allocated a fresh buffer and discarded it every frame, 120×/sec. That's the allocation + GC churn this pair represents.
Both costs came from useFrameCallback(), the Reanimated callback I was using to prepare the animation state every frame:
- Allocate a fresh buffer
- Compute ball positions
- Write values into the buffer
So the worklet was not losing time because the ball position math was complex. It was losing time because of how I was rebuilding and rewriting a large JS array every frame.
At this point, the goal looked obvious: remove the allocation, reduce the indexed writes, and stop rebuilding the buffer every frame.
I was happy. I thought I had found the bottleneck.
I hadn’t.
The False Victory
Double-buffer arrays, 80fps → 104fps
So I had two issues to fix:
- allocation churn: allocating a new buffer every frame.
- indexed writes: writing many computed indexes per frame, which hit Hermes’ generic property-write path.
Allocation churn
First, I tackled the allocation churn.
The solution had two parts:
- No
new Array(...)allocation and no.fill(0)on every frame, which ballooned the memory and pressured the GC. - Allocate two reusable arrays once at module scope, fill in place, and flip which one you write each frame.
Why two buffers, not one?
Because Reanimated needs a new reference to propagate the update. If I reused one array and assigned ballBuffer.value = buffer every frame, the reference stayed the same and Skia could miss the change.
Two buffers fixed both issues:
- different reference → Reanimated propagates the updates.
- different write/read buffer → safer handoff between reading and writing.
Result:
This fix improved frame performance from 80fps → 104fps and reduced missed frames from 42% → 15%. A big, real, measured jump.
It was a real win, and it made me confident I was on the right track, which was a morale boost. But it didn't directly address the indexed-write cost.
Why call it false victory?
104 wasn't 120, and I'd convinced myself I knew why. Both of those would come back to bite me.
The Relapse at 128 Balls
64 balls clean, 128 balls janky. Hermes again... right?
So after that "victory", I reduced the ball count from 128 border and 64 oscillating to 32 balls each.
I also played around with the Metaball knobs to get the desired look, however, I was more concerned with the performance.
The performance was the best so far, holding at ~122fps→123fps with ~0.1% missed frames.
This was perfect, however, I couldn't get the look I wanted.
So I increased the ball counts to 64 border and 64 oscillating and the stutter came back.
Smaller this time but still real.
So here's what we have so far, after the double-buffer fix:
| Ball Count | Avg. FPS | Miss % |
|---|---|---|
| 64 (32 + 32) | ~122–123fps | ~0.1% |
| 128 (64 + 64) | ~115fps | ~1.5% |
I thought, Hermes again, right!
The numbers don't lie: adding more balls meant more writes, which dropped the frames.
It wasn't that one-to-one though, and that's what I found out.
The reflex "Hermes again" was untested.
So I measured the write loop directly.
I logged inside the worklet itself at the write loop, logging write-ms every 60 frames as a baseline and on every dropped frame, in a release build.
The results were shocking with writes maxing at 0.90ms, even the dropped frames weren't slow they were even faster. That was the smoking gun 🔫. If the write loop finishes under 1ms, even on dropped frames, then the bottleneck must be something downstream.
One Variable Was Actually Three
"More balls" was never one knob
I kept trying ball counts, yet something felt off.
I even reduced the number of writes by making some passed values static, like the ball radius.
Nothing changed.
It must be something else that I was missing, maybe I was looking in the wrong place.
I thought I was testing one variable:
more balls
But I was actually changing three things at once.
| Variable | What changed | Why it mattered |
|---|---|---|
| JS writes | More balls meant more buffer writes | More Hermes indexed writes |
| Shader loop work | More balls meant more metaball evaluations per pixel | More SDF work inside the fragment shader |
| Pixel count | Supersampling increased the rendered pixel area | The shader ran over many more fragments |
The first one was obvious because I had just fixed the array allocation problem.
The second one was easier to miss.
Inside the shader implementation, each pixel loops over the active balls. So increasing the ball count does not just add more data to pass from the worklet. It also adds more work for every pixel the shader touches.
However, the third variable was the real trap: supersampling.
I use supersampling to keep shaders clean and crisp. The idea is simple: render the shader into an offscreen layer at a higher scale, then downsample it back to the logical size for antialiasing.
In code, it looks like this:
// ss = device pixels × the supersample factor.
const ss = PixelRatio.get() * SUPERSAMPLE_SCALE;
...
// outer scale(1/ss) downsamples the ss× layer back to logical size (the AA)
<Group transform={[{ scale: 1 / ss }]}>
{/* inner scale(ss) renders the metaball field at ss× density (the cost) */}
<Group
transform={[{ scale: ss }]}
layer={
<Paint>
<RuntimeShader .../>
</Paint>
...
That looks harmless, but the cost is quadratic in scale.
Because scaling affects both the width and the height, the pixel count gets squared: pixel cost ≈ ss².
So on a 3× device, even SUPERSAMPLE_SCALE = 1 means the shader can run over roughly 9× as many pixels.
And every one of those pixels loops over the active balls.
So the cost wasn't the ball count itself.
It was closer to:
active_balls × shaded pixels
And supersampling was the hidden multiplier:
(PixelRatio × SUPERSAMPLE_SCALE)²
The full formula comes next.
That changed the whole investigation.
The relapse at 128 balls was not clean proof that Hermes writes were still the bottleneck.
It was a mixed test, with hidden variables I was yet to discover.
I had increased the writes, increased the shader loop work, and increased the amount of fragment work all at the same time.
The real cost model was bigger than I thought.
The Real Cost Model
Formula
active_balls × logical_resolution × (PixelRatio × SUPERSAMPLE_SCALE)²
This was the formula that I should've used from the start, as it separates the real costs:
| Term | Meaning |
|---|---|
active_balls |
how many balls the shader evaluates |
logical_resolution |
how many logical pixels the effect covers |
(PixelRatio × SUPERSAMPLE_SCALE)² |
how much supersampling multiplies the shader calculation |
The 128-Ball Example
27 billion ball evaluations per second
Let's apply the formula to the 128 balls case:
The exact number matters less than the scale. This was not a perfect GPU cost estimate, it was a way to see the order of magnitude I was asking the shader to handle.
active_balls = 128
logical_resolution ≈ 402 × 874 = 351,348
(PixelRatio × SUPERSAMPLE_SCALE)² = (3 × 0.75)² ≈ 5.06
So the estimated work per frame was:
128 × (351,348 × (3 × 0.75)²) ≈ 227.7 million estimated ball evaluations per frame
At 120fps:
227.7 million × 120 ≈ 27.3 billion ball evaluations per second
That was the number that I should've been looking at from the start.
Not just how many balls I had or how many values I wrote.
The real cost was how many times the shader had to evaluate those balls across the rendered pixels.
Proving The Formula
The formula was still just a theory. To test it, I changed exactly one variable and watched the frame time follow. Same 128 balls, same buffer writes, same shader, changing nothing but SUPERSAMPLE_SCALE:
| Config | Rendered pixels | Avg. FPS | Miss % |
|---|---|---|---|
| 128 @ 0.75 (ss² ≈ 5) | ~5× logical | ~115fps | ~1.5% |
| 128 @ logical (ss² = 1) | 1× logical | ~124fps | ~0% |
Same balls. Same writes. Same shader. The only thing that changed was how many pixels the shader ran over, and the stutter tracked it exactly. Drop the pixel count and the jank vanished; the frame held a clean 120fps.
That was the confirmation. Ball count and writes were not the whole story. After the double-buffer fix, the dominant cost was fragment shader work: evaluating those balls across every rendered pixel.
That made the ~115fps result much less mysterious.
The frame did not need one huge bottleneck to miss. It only needed a small amount of extra fragment work repeated hundreds of millions of times.
After the double-buffer fix, Hermes was no longer the main suspect. The bigger cost was now fragment work.
The ProMotion Twist
107fps that felt like 60
Comparable fill load, yet the iPhone 16 Pro felt worse than the Samsung S24.
Not because the average FPS was dramatically lower. At 0.75, the iPhone averaged ~107fps, while the S24 averaged ~115fps.
Close. But the iPhone missed 11.2% of its frames against the S24's 1.5%, and those misses were far more visible.
The key was ProMotion. On supported iPhones, the display can dynamically vary its refresh rate up to 120Hz. That means the target frame budget is tiny:
120Hz = 8.33ms per frame
ProMotion dynamically selects from supported presentation rates. But for this animation, the frame-time histogram showed two dominant cadences: frames near the 8.33ms target and a large pile-up around 16.7ms, the length of a 60Hz frame.
So a modest overshoot could read as a 2× frame-rate collapse, not gentle degradation.
At 0.75 supersample, the same ~5× fill from the last section, 342 frames grouped around ~16.7ms, versus just 3 frames at logical resolution.
That was the twist.
And it's why 107fps was misleading. It looked like the frame pacing was flickering between frames near the 120Hz cadence and frames lasting roughly 16.7ms.
Most frames made the 120Hz deadline.
But enough frames missed. And on an adaptive panel each miss could become a frame displayed for roughly twice as long. Those misses became visible as rhythm changes: smooth, smooth, smooth, hitch, hence the stutter.
The S24 produced far fewer missed frames under the same configuration: 1.5% versus 11.2%. My measurements establish that difference, but not whether it came from Android’s presentation policy, GPU throughput, driver behavior, or another platform-specific factor.
Know your platform’s failure mode: on a high-refresh display, average FPS can hide the problem. Frame-time distribution and missed-frame percentage reveal the rhythm your eyes actually see.
What Did Not Help
Every fix that made sense and changed nothing
Reducing Only the Worklet Writes
Tackling this was my first reflex after falsely diagnosing it as an upstream issue.
Freezing static values and experimenting with typed arrays may have reduced or changed the write path, while per-ball SharedValues changed the data flow entirely.
None of them improved frame time because the double-buffer had already made the path cheap enough.
Treating Ball Count as the Whole Cost
"More balls = more cost" was one part of the equation, actually one of three. The real driver was active_balls × logical_resolution × supersampling², which is why the same 128 balls could be clean at logical resolution and janky at 0.75 of the device PixelRatio.
Assuming the GPU Was Cleared Too Early
Those microsecond Metal-submit times from the start? They only cleared the CPU→GPU handoff, never the GPU's actual work. Instruments could show me the GPU was busy, it just couldn't tell me how busy, or with what. So fragment fill stayed unmeasured the entire time, and turned out to be the dominant hidden cost.
Look again at that first Instruments trace: an empty encoder track sitting directly on top of a GPU track that never goes quiet. The story was right there. I just couldn't read it yet.
What Actually Helped
Two buffers, fewer pixels, and a histogram
Reusing the Buffers
The double-buffer approach was one of the best solutions. It introduced a more robust way of handling the data, fixed the allocation churn, and pushed the investigation downstream, where most of the remaining frame cost lived.
Two module-scope arrays, filled in place and flipped each frame, killed the per-frame allocation and its GC churn. That took me from 80fps to 104fps, while missed frames dropped from 42% to 15%.
This was the one CPU-side change that genuinely mattered, back before fill became the wall.
Reducing the Per Pixel Evaluations
This was the MVP of all the solutions.
Pixel density was the major cost delaying frames past the 8.33ms budget, and it scaled with the square of the device PixelRatio (ss²), so it climbed fast.
It had two knobs:
SUPERSAMPLE_SCALE: dropping from0.75to logical resolution cut the fill~5×(227.7M → 45Mball evaluations per frame). On the iPhone 16 Pro that took it from107 → 119fps,11.2% → 0.3%missed, and the16.7mspile-up collapsed from342 → 3 frames. On the S24:115 → 124fps,1.5% → 0%.Active balls (
128 → 64): the cost is a productactive_balls × rendered_pixels. So I could attack it from the other side too.
Halving the active count took it from115fpsand1.5%missed frames →122–123fpsand0.1%missed frames, for the same reason: fewer balls means fewer evaluations at every pixel.
Both knobs trade quality for frames. Breaking that trade-off means going into the shader itself, that's at the end.
Measuring Frame-Time Distribution
On paper, the two runs weren't far apart: 107fps vs 119fps.
The distribution told the real story:
-
p90:17.02msversus8.81ms - Missed frames:
11.2%versus0.3%
That's the same pile-up ProMotion made visible, and without it, I
would've stayed lost in the sauce, chasing an average that never
represented a stable cadence to begin with.
Separating JS Cost From Fragment Cost
Every breakthrough came from measuring the two halves independently.
First, I timed the writes inside the worklet. Then I held the ball count constant while only changing the supersampling level (1, 0.75, etc.).
The in-worklet timing largely ruled out the write loop as the cause of the dropped frames:
-
0.34msmedian -
0.90msmax -
0of157dropped frames spent more than2mswriting
The writes were actually faster on the frames that stuttered.
Then, with the ball count fixed, changing only the pixel count moved performance from 107fps to 119fps.
That strongly implicated the fragment work.
This separation revealed where the elusive dominant cost lived.
Takeaway
The bug was never where I first looked
The Fix That Works Can Become the Bias That Blinds You
Twice I was confidently wrong, and both times it was the previous win talking.
First: "it's the GPU".
It was the worklet.
Then, after the double-buffer fix: "it's Hermes again".
It was fragment fill.
The model that won last round is the first suspect you should distrust this round.
That bias felt reasonable because the previous bottleneck had been real. But once I had reduced it, continuing to blame it stopped being evidence and became habit.
Isolate Your Variables Before You Trust the Result
The whole cliff hid behind three knobs that moved together:
- worklet writes
- active-ball shader iterations
- rendered pixels from supersampling
"More balls" looked like one variable.
It was actually three.
Nothing made sense until I pinned two and moved one at a time.
Each Phase Needs Its Own Instrument
FPS said frames were late.
Metal trace cleared the submit.
Time Profiler found the thread, then the function.
But to understand the write loop cost, the most reliable measurement came from timing it inside the worklet itself.
Each tool answered only one part of the question.
Because Instruments was Apple’s native profiler, I wanted to treat it as the single source of truth. But even a native tool is only authoritative about what it can actually measure.
Know Your Platform’s Failure Mode
Under comparable settings, the iPhone produced many more missed frames than the S24, and those misses were more visible.
Average FPS hid the nature of the failure.
On a high-refresh display, the frame-time distribution and missed-frame percentage map closely to the stutter your eyes actually see.
The bug was never where I first looked.
The method is what got me there.
What's Next: Attacking the Shader Itself
The knobs I turned, fewer balls, less supersampling bought back
the frames by trading away quality. That works, but it treats the
symptom.
The cost model says the real enemy is the product itself:
active_balls × rendered_pixels. Every ball, evaluated at every
pixel, every frame. As long as that multiplication stands, quality
and performance stay locked in a trade-off.
So the next investigation goes into the shader itself: can that
product be broken? I'm experimenting with a few approaches, and
early signs say yes, but I've been confidently wrong twice in this
post already, so I'll let the measurements speak in part two.
Final Debugging Checklist
This is the checklist I wish I had before starting. Use it if you're working on something similar.
Setup
- [ ] Test on a real device, in a release build. Simulators lie.
- [ ] Commit each experiment with the config and numbers. Reconstructing performance results later is painful.
Measure before changing anything
- [ ] Record a baseline first.
- [ ] Do not trust average FPS alone. Capture frame-time distribution and missed-frame %. My
107fpsaverage hid a16.7mspile-up.
Locate the phase
- [ ] Check JS thread vs UI thread first. Clean JS + busy UI usually means worklet or native-side work.
- [ ] Tiny GPU submit time does not clear the GPU. It only clears the CPU → GPU handoff.
- [ ] Use Time Profiler to find hot leaf functions. Look up unfamiliar ones before building a theory.
If the CPU side is hot
- [ ] Kill per-frame allocations. Pre-allocate buffers and reuse them.
- [ ] Double-buffer when the reactive system needs a new reference to propagate updates.
- [ ] Verify with in-worklet timing. If writes stay under
1mson dropped frames, stop optimizing that path.
If frame cost scales with pixels
- [ ] Write the cost model before guessing:
work_per_pixel × rendered_pixels. - [ ] Treat supersampling carefully. Scaling width and height means pixel cost grows with
ss². - [ ] Change one variable at a time. “More balls” was not one variable, it moved writes, shader loop work, and pixel count together.
- [ ] If fill is the wall and you can't trade more quality, the cost structure itself is the target, that usually means shader-level changes.
If the device has a high-refresh display
- [ ] Check the
8.33msboundary, not just the average FPS. - [ ] On ProMotion, a small miss past the
120Hzwindow can present like a16.7msframe, a visible2×hitch.
Bias check
- [ ] After every fix, re-measure from scratch.
- [ ] The last bottleneck you found is the first suspect to distrust.















Top comments (0)