Skip to content
Alberto Vasquez
← Writing

Skia Graphite under a poker table: two GPU stacks, one breaking API, and no speed claim yet

· 12 min read· updated

We moved our React Native poker table onto Skia Graphite, and nothing we filmed drew differently. What changed is everything around the pixels.

Hash.Poker’s table is not a list of rows. It is a Skia canvas: a neon rail, a countdown ring that redraws every frame while a player is on the clock, a winner halo drawn by a shader, chips, a pot pill, and animated gifts one player throws at another. Graphics are the product, and they run on React Native Skia.

On 2 October 2026 William Candillon published React Native Skia 3. It is a new package (react-native-skia, unscoped) on a new GPU backend: Skia Graphite, running on Dawn, Google’s WebGPU implementation, which drives Metal on Apple platforms and Vulkan on Android (migration guide). We took version 3.4.4 on 16 October, on iOS, into our 1.14.0 binary.

This post is what Graphite turned out to mean for our app, and what we have not measured, because the most tempting sentence in a post like this is “and it got faster”, and we cannot write it.

One piece of mechanics up front: we landed the binary as two commits, Expo SDK 58 and React Native 0.88 first, verified on the simulator on its own, then Graphite, verified again. So “on Graphite” below is compared against a build that already had the new SDK, and the felt results are about the renderer alone.

Lesson 1: the felt drew the same, and that needed measuring, not watching

The headline result is a negative one. Across six stills of the live table and a 50 second film of a full hand cycle (acting ring, turn and river in flight, showdown reveal, chip collect, winner halo, the +98 rise over the winner, the clean-table hold, the next deal), every beat was present, in order, and looked the way it did on the previous backend.

“Looked the same” is a weak claim on its own, so we measured the film. Every frame at 30fps, cropped to the felt, reduced to per-frame statistics:

measure result what it would have caught
near-black frames 0 of 1,492 a dropped Metal layer blanking the canvas
brightness range 52 to 71 the felt flashing or dropping out mid-hand
longest identical-frame run 47 frames (1.57 s) a frozen canvas

The last row is the useful one. Our animation queue deliberately holds a settled table for a moment between hands, and that hold is the longest still stretch in the film. So 1.57 seconds is a ceiling with a known cause: a stalled Graphite recording would have shown up as a run longer than our own intended pause.

The single most important frame was the countdown ring on a seat that was acting. More on why in lesson 3.

We also played real hands on the new backend in the dev client with our scenario runner: 25 hands, 110 actions, 3 rebuys, zero reconnects, chips conserved on leave. Not a rendering test, but proof that a canvas re-recorded on every commit did not get in the way of the game. What none of this is: an automated pixel comparison. A person read the frames, and the numbers bound the failure modes we could name.

Lesson 2: native and web are now two different GPU stacks

This is the one that changes how we work.

Graphite is native only. On the web, React Native Skia 3 still draws through CanvasKit on WebGL, the same canvaskit-wasm 0.41.0 that our web loader already pinned. Before the upgrade, native and web were at least the same Skia family drawing through different surfaces. Now the iPhone renders through Skia Graphite, Dawn and Metal, and the browser renders through a WebAssembly build of Skia on WebGL. Same components, same props, entirely different backends.

That matters because our fastest inner loop is web: the layout lab and headless-Chrome films of the live table. We already knew the web lab lies about native in at least one specific way: on iOS, a <RoundedRect> whose radius is at least half its width or height throws Invalid properties for rounded rect, and web CanvasKit draws the same props without complaint. After Graphite, that divergence reads as a symptom: the web lab was never testing the native renderer, and now it does not even share a GPU backend with it.

Two practical consequences.

The web pin chose our native version. The 3.x line moved fast: six minor versions in two weeks, 3.6.1 shipping the day before our cut. The ticket’s original plan, “pin whatever 3.0.x has been out and boring”, had nothing boring left to pick. We chose 3.4.4 for two reasons that have nothing to do with how the felt looks on a phone. It keeps canvaskit-wasm at 0.41.0, so the web CDN pin did not have to move. And its native Apple binary is version 154.3.0, the same one the newest releases ship, so it is the most-exercised binary of the candidates even though the JavaScript around it is older. In a young line, the native binary a version ships with is a better signal than the version’s age.

The web constraints did not move. The browser’s limit of roughly 16 live WebGL contexts still applies, so the design rules it forced on us stay: one shared canvas per button cluster, and chips drawn as plain React Native views on web because a canvas per chip blows the cap. Graphite’s improvements are native improvements. Anything we design around them still has to survive the web build.

The rule we took from this: a web film is evidence about choreography and layout, never about the native renderer. For anything that touches how Skia draws, the simulator is the test.

Lesson 3: the one breaking API sits in the hottest path

The migration guide says “the drawing API is the same as in v2: components, hooks, shaders, and the imperative Skia API all work as before.” For declarative components that was true for us. For imperative paths it was not: in Skia 3, SkPath is immutable. Every verb moved to a new SkPathBuilder, and you call .build() to freeze the result. Counted in the published type definitions of react-native-skia@3.4.4:

method on SkPath on SkPathBuilder
moveTo 0 1
lineTo 0 1
addArc 0 1
addCircle 0 1
close 0 1

The guide does not mention SkPath, PathBuilder or Path.Make at all; we searched it for each. The fix is mechanical:

const path = Skia.PathBuilder.Make();
path.addArc(rect, -90, 360 * progress);
return path.build();

The compiler finds every site, because a method that no longer exists is a type error. Twelve of our files import Skia. Three used the mutable path API: the lobby’s hero frames, the long-press stats ring, and the countdown ring.

The interesting part is which three. The lobby frames and the stats ring are built once. The countdown ring rebuilds its arc every frame, inside a Reanimated worklet on the UI runtime, for the entire time a player is on the clock. The only code Graphite forced us to rewrite is the code that runs most often and on the thread whose job changed most. That is why the frame of a seat carrying a live acting ring was the frame that mattered most in lesson 1.

There was a better fix available, and we did not take it. A performance audit we ran two weeks earlier had already flagged that the ring allocates paths on every frame, and recommended building the circle once and trimming it with the Path end prop, which exists in 3.x. Our own decision ticket for Graphite said to do that fix inside the upgrade rather than porting the per-frame build. We reversed that on the day: trimming a static path is not guaranteed to look identical to rebuilding an arc, and a change to how the dial looks is a product decision that had not been made. So the ring is a line-for-line port, still allocating per frame, and the better fix waits for a look review. An API break invites a refactor. Keep the refactor out of the upgrade when it changes what players see, or a felt that looks different afterwards has two possible causes again.

Lesson 4: proving the backend shipped meant reading the right binary

Our first check that Graphite was actually in the build came back negative. It was a false negative, and an instructive one.

In our debug simulator build, pkrrDev.app/pkrrDev is a 121K launcher stub. The app’s code, Skia included, lives in a separate pkrrDev.debug.dylib of about 223 MB. A strings search of the stub finds no Skia at all. The dylib is where the answer is:

symbol count in the debug dylib
RNSkia 3,710
graphite 266
skgpu 260
SkPathBuilder 208

Dawn is linked into that same dylib rather than shipped as a separate framework: the app’s Frameworks folder has no Dawn, Graphite or WebGPU entry, while the dylib holds hundreds of Dawn strings.

The general point: when you verify that a native backend shipped, find out where your build system actually puts code before you search it. “No matches” from the wrong file reads exactly like “not there”.

Lesson 5: a package rename turned the renderer into a release boundary

We ship JavaScript updates over the air between binaries, routed to the binary generation they were built for. A dependency upgrade usually does not affect that. This one did.

After the Graphite commit, our JavaScript imports react-native-skia. The previous binary, 1.13.0, links the old scoped @shopify/react-native-skia. So from that commit onward, nothing on main can be sent to 1.13 testers: the JavaScript would ask for a native module the binary does not have. We cut Graphite into a new generation, 1.14.0, on its own update channel, kept the 1.13 build installable as a known-good comparison without Graphite, and noted that any 1.13 hotfix has to branch from the commit before the rename.

That is about how we distribute, not about Skia. But anyone running over-the-air updates should know that Skia 3 is not a drop-in dependency bump. The rename makes it a binary-generation event, whether or not a single pixel changes.

A smaller packaging change pointed the right way. Skia 2 downloaded its native binaries in a postinstall script, which pnpm blocks by default, so it needed an entry in our install-scripts allow-list, and a suppressed script there had produced confusing native build failures before. Skia 3 ships its binaries as ordinary npm dependencies (react-native-skia-graphite-apple-ios and friends) with no postinstall. The allow-list is now empty.

Lesson 6: a regression check is not a removal check

One of our workarounds lives right next to the canvas. Our gifts animate as a clip and then swap to a still image, and that swap used to flash a blank frame on 4 to 6 of every 10 sends. The fix mounts the still hidden ahead of the swap and reveals it in the same commit that removes the clip. A new renderer could plausibly have disturbed that timing, so we filmed it: 15 sends across four gifts at 60fps, measuring the gift’s painted width on every frame through the swap. No blank frame in any run.

It is tempting to read that as “the workaround is no longer needed”. But every one of those runs had the workaround in place: they prove Graphite did not regress the handoff, not that the mitigation can go. That second question needs runs with it removed, a positive control proving the measurement can still go red, and more runs, since at the historical rate of about 20% ten clean runs happen one time in nine by luck. It is its own ticket.

What we are not claiming

No performance numbers. We took none on Graphite. The architectural change is real and documented: in v2, <Canvas> drew its frames on the UI thread; in v3 the scene is recorded once per React commit and a native thread pool replays it, while the UI thread only applies animated values. That sounds like a win for a per-frame ring and a shader halo.

But our audit two weeks earlier found that the stutters players notice are a pacing problem, not a render-speed one. The felt deliberately plays a median 5 to 8 seconds behind the server during busy hands, and the skips are the queue’s catch-up rules doing their job. Taking drawing off the UI thread does not fix a queue that is behind on purpose. So, honestly: we adopted a faster renderer for reasons that were not speed. We took it because v2 stops at 2.14 and v3 is where the work continues.

The new architecture also changes which of our performance work matters, in both directions, and none of it is measured yet:

  • Re-renders now cost a recording. If a lobby update re-renders every seat’s canvas, each one becomes a fresh native recording. Skipping needless re-renders was already on our list; Graphite raises the price of not doing it.
  • Our old profiles describe a binary we no longer ship. A Ganesh profile that says “the UI thread is busy in Skia” says nothing about a backend that replays off the UI thread. The canvas-consolidation question we had open has to be re-answered from a new profile, not carried over.
  • One constraint may have gone away. Under Ganesh a GPU texture belonged to the thread that created it. Graphite’s documentation says GPU-backed images can be drawn from any canvas and any worklet runtime. That reopens a design question in our planned sprite-atlas player for gifts, and we have not tested the answer.

No binary-size number either. The debug simulator build with Graphite is 442 MB unpacked. An older debug build we still have, on the previous SDK and Skia 2, is 385 MB. That gap mixes an SDK upgrade with a renderer change, and a debug build says little about the App Store download, so it is not a Graphite number and we are not quoting one. The release artifact’s size is still unread.

Not tested: Android (we do not ship it; Graphite needs API 26 and Vulkan), a real device for the new generation, and the long-press stats ring on screen, which rests on sharing the countdown ring’s call shape.

If your app draws with Skia and you are about to take Graphite

  • Search for Path.Make() first. It is the size of your migration, and the guide will not tell you. Then check which of those paths are built per frame: that is where to point the camera.
  • Treat web and native as different renderers from now on. A browser film proves layout and choreography, not drawing.
  • Pick the version by its native binary and by what it forces on your web pin, not by how long it has been out.
  • Prove the backend shipped by searching the file that holds your code, not the app’s main executable.
  • If you ship over-the-air updates, plan the rename as a new binary generation.
  • Name the failure modes before you film (a blanked canvas, a frozen frame) and measure for them.
  • Do not write “faster” until you have re-profiled on the new backend. Our honest position is that we do not know yet.

References

  • React Native Skia v3 migration guide - the package rename, platform requirements, removed Canvas props, rendering off the JS and UI threads, and textures usable from any thread.
  • wcandillon/react-native-skia - the v3 repository.
  • Shopify/react-native-skia - the v2 line, which stops at 2.14.
  • Dawn - Google’s WebGPU implementation, which Graphite runs on.
  • SkPath and SkPathBuilder method counts read from the published type definitions of react-native-skia@3.4.4 (lib/typescript/src/skia/types/Path/).

Related