For three months I told anyone who asked that I'd chosen Skia over WebGL for my React Native renderer. It's a clean sentence to say out loud. It's also wrong.
On web, the renderer I built doesn't avoid WebGL - it calls straight into it. packages/render-web/src/web-surface.ts:31 hands the canvas to kit.MakeWebGLCanvasSurface(canvas), and CanvasKit resolves that to canvas.getContext("webgl2"). Skia vs WebGL was never a real fork in the road, because Skia is a 2D drawing API that targets GPU APIs - WebGL on web, Metal on iOS, OpenGL ES on Android. It doesn't compete with any of them.
What I actually decided was to adopt one drawing API - paths, text, blurs, colour filters, drawAtlas, PictureRecorder, SkSL runtime shaders - and let it target three backends, instead of hand-writing a 2D renderer myself on top of a raw GPU context. That decision is real, it paid off in specific and measurable ways, and it cost specific things most "why I chose X" posts leave out. Here's the whole ledger.
Skia vs WebGL is the wrong question
The title of this post is a trap I set for myself, and a skeptical reader is right to call it out before I do: Skia and WebGL aren't alternatives, they're different layers of the same stack. Skia is the drawing API. WebGL, Metal, and OpenGL ES are the GPU backends it draws through.
The proof is in the web adapter itself:
// packages/render-web/src/web-surface.ts:30-33
export function createWebSurface(kit: CanvasKit, canvas: HTMLCanvasElement): CanvasLike | null {
const surface = kit.MakeWebGLCanvasSurface(canvas);
if (!surface) return null;
const ck = surface.getCanvas();Two more call sites do the same thing (postfx-surface.ts:57 and a resize rebind at :216). CanvasKit's only GPU path on the web is a WebGL context wrapped in a Skia-shaped API - the shipped 0.40.0 bundle doesn't even have WebGPU glue compiled in, despite the .d.ts declaring it. CanvasKit does keep a CPU rasterizer underneath for when the GPU surface can't be built, but that's a failure mode rather than a second backend, and it's one I handle badly - see "What you give up".
So "I chose Skia instead of WebGL" is a category error, and the honest version is smaller and less dramatic: I chose not to write the renderer. Paths, text layout, blur, colour filters, atlas batching, and shader composition all arrive as one API, on all three targets, instead of being three separate things I'd have to build myself on top of a raw context.
One period note, because I published the opposite six weeks ago. The CanvasKit web parity post (2026-06-15) said the engine shipped no web build, that a grep of packages/*/src returned zero CanvasKit references, and that the engine never calls CanvasKit directly. All three were true when I measured them on 2026-06-14. packages/render-web landed on 2026-06-30, lifted out of the live site's own adapter, and it calls CanvasKit directly by design. That post isn't wrong about June - it just stopped describing the repo two weeks after it shipped.
What a raw GPU context hands you (and what it does not)
There's no decision record for any of this. I looked - find docs -iname "*decision*" -o -iname "*adr*" -o -iname "*rfc*" returns nothing in the engine repo. The only "alternative considered / why chosen" table in the codebase covers build tooling, not rendering. The closest thing to a rationale is a market-landscape table in docs/design/ecosystem.md, dated 2026-04-20. Everything below is reconstructed from that snapshot and from re-checking the ecosystem myself, not recovered from a document that settled the question at the time.
As of late July 2026, a React Native project reaching for GPU rendering has four real options, and they sit at different layers:
expo-gl(57.0.2, ~259k downloads/week per the npm downloads API) hands you a rawWebGL2RenderingContext-shaped object. It's actively maintained and New-Architecture-ready, but it's a context, not an engine - paths, text, and batching are yours to write.react-native-webgpu(0.6.3, ~17.3k downloads/week) is the compute-capable successor, New-Architecture only, pre-1.0 with no published stability claim. Younger layer, same shape of problem: a context, not a drawing API.react-native-game-enginelast published 1.2.0 on 2020-06-09. Its download count is volatile rather than monotonically falling - 2,235/week when I checked for the gap post in May, about 1,600 in early July, 1,946 for the week ending 2026-07-24 - and the repo isn't archived, so "dead" is an inference, not a fact. The durable signal is dormancy, not direction: it hasn't shipped in over six years, and it renders through React Native views rather than the GPU.@shopify/react-native-skiais the one that isn't a raw context. It's a complete 2D drawing API - the same one I'm describing above.
I covered this landscape in more depth in the React Native game engine gap; the axis that post spent was layer-of-the-stack. The axis this post spends is different: API surface. A raw GPU context gives you triangles and shaders. Skia gives you drawText, drawPath, ColorFilter, gradients, and blur in the same call surface as your sprites - which is exactly the chart-axis, label, and gradient work a non-game React Native prototype needs, not just a shmup. On a raw GL context, every one of those is a library you have to write or vendor first.
One renderer, three backends
The engine's render package doesn't import Skia at all. The Skia module arrives as a factory parameter, and ten structural *Like interfaces stand in for the real types:
// packages/render/src/frame-renderer.ts:150-158
export interface FrameRendererModule {
XYWHRect(x: number, y: number, w: number, h: number): RectLike;
RSXform(scos: number, ssin: number, tx: number, ty: number): RSXformLike;
Paint(): PaintLike;
PictureRecorder(): PictureRecorderLike;
ColorFilter: ColorFilterFactoryLike;
RuntimeEffect?: RuntimeEffectFactoryLike;
ImageFilter?: ImageFilterFactoryLike;
}The two seams that plug a real Skia module into that interface are symmetric by construction:
// packages/react/src/skia-bridge.ts - native
export function createSkiaFrameRenderer(Skia: SkiaModuleLike): SkiaFrameRenderer {
return createFrameRenderer(Skia);
}
// packages/render-web/src/web-frame-renderer.ts - web
export function createWebFrameRenderer(kit: CanvasKit): FrameRenderer {
return createFrameRenderer(createWebSkiaModule(kit));
}One factory, two thin adapters, three GPU backends behind them:
The render package is 2,822 lines across 10 files with zero @shopify/react-native-skia imports and no peer dependency on it at all - only assets, ecs, and math. Its 193 tests are already counted inside the engine's whole-repo run (2,586 ran, 2,571 passing - the 15 non-passes are root-runner cross-package resolution artifacts, not real failures; the owning packages are green when run standalone) - they're not extra tests stacked on top of that total, they're the same tests viewed at package granularity. The native drawing pipeline this layering supports - imperative drawAtlas batching sprites into one draw call per atlas texture - is the subject of the Skia Atlas post; this post is about the layer underneath it, not a repeat of it.
Write the shader once
The same one-API-three-backends story holds for shaders. The engine has 21 hand-written SkSL programs (12 in postfx, 9 in render), and every one compiles as plain SkSL - no platform branches, no web-only fork.
// packages/postfx/src/shaders.ts:13-26
uniform shader src;
uniform float2 resolution;
uniform float intensity;
uniform float radius;
uniform float3 color;
half4 main(float2 coord) {
half4 c = src.eval(coord);
float2 uv = coord / resolution;
float2 centered = uv - 0.5;
float d = length(centered);
float v = smoothstep(radius, radius * 0.5, d);
return half4(mix(half3(color), c.rgb, mix(1.0 - intensity, 1.0, v)), c.a);
}I fed all 12 postfx shader constants to kit.RuntimeEffect.Make against a real CanvasKit build and all 12 compiled unmodified, with each shader's uniform float count matching what the engine's own packUniforms writes. That's a real result, and it's also a narrow one. It proves compile parity on the web backend, plus CPU-rasterized pixel output, on the machine I ran it on. It does not prove the same source compiles on the native backends - I have not run that probe - and it does not prove GPU-path visual parity on an actual device. Both checks are still ahead of me. The structural reason to expect them to pass is that there is one shader source record with no platform conditionals in it, but expecting and verifying are different words.
What I can say about native cost is bounded and indirect, and it's a number the zero-alloc and Atlas posts already leaned on: in the E8 sweep - five scenarios across three Android phones, 15 cells - the worst cell was a Pixel 10 running particle-storm at 9.52 ms against a 16.67 ms frame budget. That is a whole-frame p95 from a single run (n=1), with shaders running inside it. It does not isolate the shader pass, so it cannot tell you what postfx costs; it only tells you the frame it lived in had headroom left.
What you give up
This is the part comparison posts tend to skip, so here's the full list.
No vertex stage. RuntimeEffect is fragment-only, and RN-Skia does not expose vertex shaders - to be precise about it, Skia's core engine has SkMesh, which does support vertex programs, but RN-Skia's public surface doesn't reach it. <Vertices> takes vertex data, not vertex code. Every capability probe I ran against real CanvasKit confirms the boundary: gl_Position is rejected as reserved, in/out varyings are rejected, compute-style layout(local_size_x) buffers are rejected, imageStore is an unknown identifier, and even a loop bound that isn't a compile-time constant gets rejected. Every per-sprite transform in this engine is CPU-side JavaScript packing RSXform arrays - there's no GPU skinning, no GPU particles, no displacement shader.
"Bloom" is not a bloom. The engine's bloom effect is one eval() call against the source texture - a luma threshold and a boost, no blur, no bright-pass downsample, no multi-tap composite:
// packages/postfx/src/shaders.ts:85-91
half4 main(float2 coord) {
half4 c = src.eval(coord);
float luma = dot(c.rgb, half3(0.299, 0.587, 0.114));
float mask = smoothstep(threshold, 1.0, luma);
half3 boost = c.rgb * mask * intensity;
return half4(c.rgb + boost, c.a);
}It reads one texel. Naming it "bloom" borrows a visual-effects term for something that doesn't do the spreading a bloom implies.
drawAtlas on the web adapter has an argument-shape mismatch. The RN-Skia bridge passes object-form rects and transforms; CanvasKit's drawAtlas wants flat float arrays. The CanvasKit web parity post scored drawAtlas PASS and called the matrix 15/15 clean - but that sweep read API presence, not argument shape. Building the adapter is what surfaced the difference. It's the first documented crack in an otherwise clean parity story.
The WASM tax is real and it's paid once, up front. The full CanvasKit bundle - the one Flare needs because postfx requires RuntimeEffect - measures 8.08 MB raw and 3.25 MB gzipped, measured off the published 0.41.1 tarball on 2026-07-27. The ~2.9 MB I gave for the full bundle in the parity post is really the default build's number (it's also the figure RN-Skia's web docs quote), so the full build costs about 350 KB more than I said there. Worth knowing which build you're on: the engine resolves CanvasKit 0.40.0 transitively through rn-skia, while the live site pins ^0.41.1. The engine's own web adapter package that wraps it is a comparatively tiny 651 lines and 4.5 KB gzipped; quoting that number alone without the WASM it depends on at runtime would be misleading.
On web, error handling is thinner than the design says. The web adapter's own documentation promises a graceful null return when WebGL is unavailable. In practice, real CanvasKit throws a bare string on failure and replaces the canvas with a software-rendered clone - and neither call site in the current code catches that throw. The documented failure path is currently unreachable.
Limits
No decision record exists for the renderer choice. Everything above about "why Skia" is reconstructed from a landscape table dated 2026-04-20, not recovered from a document that made the call at the time. Treat the reasoning as sound engineering judgment applied after the fact, not as a contemporaneous record.
There is no head-to-head benchmark, and none is coming from this post. I have never built the same scene twice - once on Skia, once on a raw WebGL/WebGPU context - on one device. This is an architecture post about layering, not a shootout.
Web performance is unmeasured, full stop. The parity doc's own "Spike Results" table has sat empty since 2026-04-20 - and I closed the parity post six weeks ago by calling that spike "the next step." It still hasn't run. Its written pass criterion - 500 sprites at 30 fps or better on desktop Chrome - has never been executed. I'm naming the absence instead of guessing at a number, because a guess here would be worse than nothing.
No iOS benchmark has ever been run. That gap is sharper than the usual "haven't gotten to it yet": at rn-skia 2.4.18, the version this engine resolves, iOS runs Metal and Android runs OpenGL ES, so the untested platform isn't just a different phone, it's a different GPU backend entirely. One correction while I'm here - the parity post's diagram said "Metal / Vulkan / GL" for native, following the engine's own parity doc. That doc is wrong for 2.4.18: there is no Vulkan window context at this version, and Vulkan on Android exists only on the experimental Graphite build.
@flare-engine/render-web has zero importers anywhere in the workspace right now. The live site's web demo runs an older, local, hand-written adapter instead - and that local copy is strictly more robust than the shipped package, because it actually handles webglcontextlost/restored events, which the published package does not yet.
The engine itself is not installable. It's at 0.2.1, published to npm proper with access: restricted - that's a visibility setting rather than a separate registry, so a scoped package like this just 404s to anyone outside the org. The public flip is targeted for October 2026. Nothing here is a "try it" pitch.
And this is a one-person project. The device-envelope numbers this post would most like to have - Mali GPU behaviour, iOS background texture eviction under memory pressure - are the kind of thing that needs other people's hardware and other people's bug reports, not mine alone.
Close
The measurement I owe is specific and I'm naming it so it's checkable later: run the parity doc's own spike, 500 sprites through the web adapter on desktop Chrome, and report the frame rate against its 30 fps criterion instead of leaving the table empty. Until that's done, "it runs" and "it runs well" stay two different claims, and I've tried to keep this post on the honest side of that line.
If you're picking a rendering strategy for a React Native project - game or otherwise - the one-sentence version is: reach for a context (expo-gl, react-native-webgpu) if you want to write the renderer yourself, and reach for a drawing API (Skia) if you don't. Both are legitimate choices. They're just not the same choice.