Skip to content
·[render]·5 min read

What CanvasKit gives a React Native game engine on the web (and what it doesn't)

CanvasKit React Native parity: every Skia render API the engine uses maps 1:1 to CanvasKit WASM - but API parity is not measured performance. The honest line.

Every so often a claim floats through the React Native community: "Skia works on the web, so your RN app is one flag away from running in a browser." That claim is true and misleading at the same time.

@shopify/react-native-skia v2.6.4 does render in the browser - through CanvasKit, a WebAssembly build of the same Skia C++ library that runs natively on iOS and Android. For a React Native game engine that draws everything through Skia, this means the render API surface maps to the web cleanly. I verified that: all 15 Skia features the engine uses have a confirmed CanvasKit-WASM counterpart, at the type and API level.

That is genuinely good news. It is not the same as a shipped, benchmarked web build.

This post draws the honest line between what CanvasKit React Native gives you (a clean API mapping, a rendering path Flutter Web already walks in production) and what it does not give you yet (measured fps, a stable WebGPU backend, a working engine web target).

How RN Skia delegates to CanvasKit

The key architectural fact: the engine never calls CanvasKit directly. It calls the @shopify/react-native-skia JavaScript API, and that library decides at runtime whether to dispatch to native C++ Skia (on iOS/Android) or to CanvasKit-WASM (in the browser).

100%

The engine's Skia surface is deliberately narrow. The SkiaModuleLike type in packages/react/src/skia-bridge.ts spells out exactly which factory functions the bridge depends on:

// packages/react/src/skia-bridge.ts
// "Narrow view of the Skia module - only the factories the bridge needs."
export type SkiaModuleLike = Pick<
  typeof Skia,
  'XYWHRect' | 'RSXform' | 'Paint' | 'PictureRecorder' | 'ColorFilter'
> & {
  RuntimeEffect?: { Make(sksl: string): SkRuntimeEffect | null };
  Surface?: {
    Make(width: number, height: number): SkSurfaceLike | null;
    MakeOffscreen?(width: number, height: number): SkSurfaceLike | null;
  };
};

Every factory in that Pick is a row in the parity matrix below. Narrowing the surface this way is what makes the web path credible: you only have to verify the APIs you actually call.

The parity matrix - 15/15 CanvasKit React Native features confirmed

I cross-referenced every Skia call in the engine against the canvaskit-wasm v0.41.1 TypeScript definitions, using @shopify/react-native-skia v2.6.2. This is an API- and type-level check: it reads the type surface every call depends on, not a running build.

Engine Feature Skia API CanvasKit Status
Sprite batching Canvas.drawAtlas(image, srcRects, dstXforms, paint) PASS
Transform stack Canvas.save() / restore() / translate() / scale() PASS
Tint effect ColorFilter.MakeBlend(color, BlendMode.SrcOver) PASS
Grayscale effect ColorFilter.MakeMatrix(colorMatrix) PASS
Outline effect ColorFilter.MakeBlend(color, BlendMode.SrcIn) - 8-pass PASS
Custom shader RuntimeEffect.Make(sksl)makeShader(uniforms) PASS*
Picture recording PictureRecorder.beginRecording()finishRecordingAsPicture() PASS
Procedural atlas MakeSurface(w, h)getCanvas()makeImageSnapshot() PASS
Image decoding MakeImageFromEncoded(bytes) PASS

* PASS* = requires the full CanvasKit bundle. See the next section.

The remaining 6 rows in the full 15-feature matrix (flash effect, PostFX shaders, paint operations, rect construction, bitmap font text, camera transforms) are all PASS - they reduce to drawAtlas, ColorFilter, RuntimeEffect, or Canvas.save/restore, primitives the rows above already cover. No blockers found in the full matrix.

The bundle and context tax

There are two concrete costs on the web side that the API parity result doesn't capture.

Bundle size. The CanvasKit bundle comes in three variants:

Bundle Path Size (gzipped) RuntimeEffect
Default canvaskit-wasm/bin/canvaskit.js ~2.5 MB May vary
Full canvaskit-wasm/bin/full/canvaskit.js ~2.9 MB Yes
Profiling canvaskit-wasm/bin/profiling/canvaskit.js ~3.2 MB Yes

The engine uses RuntimeEffect for PostFX shaders (vignette, CRT, bloom, chromatic aberration). That means full is mandatory. The ~400 KB delta over the default bundle is modest for a game that already has a loading screen, and RN Skia's own CDN setup already points at full. It's a one-time, cacheable download behind a CDN and a loading screen, not a per-frame cost.

WebGL context limit. Browsers cap live WebGL contexts at 16 per page. Each <Canvas> element owns one context. The engine uses a single <Canvas> per game session, so it's a non-issue in practice - but it matters if you're planning a page that embeds multiple independent canvases.

Proof that the path is real: Flutter Web

Skia-on-WASM in production is not theoretical. Flutter Web has shipped exactly this architecture - Skia compiled to WebAssembly, running over WebGL - in production for years through its CanvasKit renderer, and more recently through skwasm, a more compact WebAssembly-Skia renderer. Both run in real production apps today.

The Flutter Web parallel matters because it answers the "but does it actually work?" skepticism before it arrives. Skia's WebGL path has been battle-tested at scale. The open questions for the engine aren't about whether CanvasKit works - they're about whether the engine's specific rendering patterns (sprite batching via drawAtlas, multi-pass color filter chains) perform adequately under WebGL.

Limits - what this is not

No measured web fps. The parity research is type- and API-level only. The engine's parity document has a "Spike Results" table, and every row in it is empty - the web performance spike was never run. There are zero measured web frames per second anywhere in the repo.

No shipped web build. Searching packages/*/src in the engine finds zero CanvasKit references, zero react-native-web imports, and no web render path. The single Platform.OS === 'web' hit in the codebase is a devtools HUD guard that returns null on web. The live web demo was dropped from scope and replaced with an MP4 fallback. The engine ships no web build; its published @flare-engine/* packages are a private 0.1.0 release (npm access: restricted).

drawAtlas on WebGL is the open risk. The parity document flags this: drawAtlas performance on WebGL may trail native Metal/Vulkan. If it falls below the threshold, the documented fallback is per-sprite drawImageRect - slower but functional. This hasn't been tested on web at any sprite count.

WebGPU is experimental. CanvasKit exposes MakeGPUDeviceContext and MakeGPUCanvasSurface for WebGPU, but the stable path today is WebGL. The WebGPU backend is not a safe target for a 0.1.0 engine.

Versions drift. The parity research used canvaskit-wasm v0.41.1 and @shopify/react-native-skia v2.6.2; current RN Skia is v2.6.4 (June 2026). The API surface at these versions is stable, but web tooling moves - re-verify before enabling the web path.

Close

The honest summary: 15/15 API parity is a green light to try the web path, not a benchmark that proves it will perform. The same Skia that drives the engine on iOS and Android compiles to WebAssembly and speaks WebGL - that's architecturally elegant and practically useful. Flutter Web already stakes money on it.

The payoff isn't just game-engine-shaped. Any Skia-driven React Native UI - an animated dashboard, a data-viz canvas, a gesture-rich onboarding flow - inherits the same browser path from the same codebase. Berek, the second game built on the engine, consumes the published @flare-engine/* render packages, and every API it uses has a verified CanvasKit counterpart. That's the dogfood proof that the render packages travel beyond one app.

What the engine doesn't yet have: measured drawAtlas WebGL fps, a real web bootstrap, or a shipped web build. Parity is not performance. The spike is the next step.

If you're evaluating @shopify/react-native-skia for a cross-platform render target, the RN Skia web docs are the right starting point - the setup-skia-web / LoadSkiaWeb bootstrap is upstream RN Skia, not engine code.

For the native side of the same drawAtlas pipeline, see Skia sprite batching in React Native at 2,000 sprites.

Related