DEV Community

Cover image for My iPhone 16 Pro Stuttered but the S24 Felt Smoother: A React Native Skia Hunt
Mohamed Eltohamy
Mohamed Eltohamy

Posted on

My iPhone 16 Pro Stuttered but the S24 Felt Smoother: A React Native Skia Hunt

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.7ms
Missed 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.

Performance Monitor

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:

Instruments numbers

  1. Encoder time was nearly empty. Microsecond submits, apart from one frame-1 spike (the shader compile).
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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.

Frame lifecycle chart showing Metal encoder time as a tiny fraction of the  raw `17ms` endraw  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
Enter fullscreen mode Exit fullscreen mode

This shows 3 unambiguous things:

  1. Metal driver is barely doing any work.
  2. JS thread is idle.
  3. 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.

Time profiler crop

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.

what runs in the useFrameCallback() and the hot functions

Both costs came from useFrameCallback(), the Reanimated callback I was using to prepare the animation state every frame:

  1. Allocate a fresh buffer
  2. Compute ball positions
  3. 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, 80fps104fps

So I had two issues to fix:

  1. allocation churn: allocating a new buffer every frame.
  2. 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:

  1. different reference → Reanimated propagates the updates.
  2. different write/read buffer → safer handoff between reading and writing.

Two buffer explanation

Result:

This fix improved frame performance from 80fps104fps 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 ~122fps123fps 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.

pipeline GPU suspect

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>
...
Enter fullscreen mode Exit fullscreen mode

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².

supersampling diagram

So on a device, even SUPERSAMPLE_SCALE = 1 means the shader can run over roughly as many pixels.

no vs full supersample

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

Cost model diagram

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_resolution402 × 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
Enter fullscreen mode Exit fullscreen mode

promotion frame pacing downshift

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 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.

Pro-Motion iPhone Histogram 128 balls

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

Not working GIF

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.

Empty encoder track, microsecond submits

GPU track showing constant activity

shader animation execution pipeline

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 from 0.75 to logical resolution cut the fill ~5× (227.7M → 45M ball evaluations per frame). On the iPhone 16 Pro that took it from 107 → 119fps, 11.2% → 0.3% missed, and the 16.7ms pile-up collapsed from 342 → 3 frames. On the S24: 115 → 124fps, 1.5% → 0%.

  • Active balls (128 → 64): the cost is a product active_balls × rendered_pixels. So I could attack it from the other side too.
    Halving the active count took it from 115fps and 1.5% missed frames → 122–123fps and 0.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.02ms versus 8.81ms
  • Missed frames: 11.2% versus 0.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.34ms median
  • 0.90ms max
  • 0 of 157 dropped frames spent more than 2ms writing

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 107fps average hid a 16.7ms pile-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 1ms on 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.33ms boundary, not just the average FPS.
  • [ ] On ProMotion, a small miss past the 120Hz window can present like a 16.7ms frame, a visible hitch.

Bias check

  • [ ] After every fix, re-measure from scratch.
  • [ ] The last bottleneck you found is the first suspect to distrust.

Top comments (0)