Start Debugging

CanvasKit vs skwasm para Flutter web em 2026: qual renderizador você deve publicar?

Publique skwasm com flutter build web --wasm quando suas dependências compilam para Wasm: ele baixa menos e renderizou 36% mais frames que o CanvasKit em uma cena pesada. No Flutter 3.47.x, mantenha-o single-threaded até a correção do crash de texto multi-threaded sair do beta.

Publique skwasm. No Flutter 3.47.4 (o stable atual, Dart 3.13.3), flutter build web --wasm entrega aos usuários do Chromium um download menor (1,65 MB vs 1,98 MB em brotli para o app de benchmark abaixo) e 32,7 frames por segundo vs 24,0 do CanvasKit em uma cena pesada. Firefox, Safari e todos os navegadores do iOS continuam recebendo CanvasKit a partir do mesmo build. Fique em um build somente CanvasKit apenas se alguma dependência ainda importar dart:html ou package:js. Uma ressalva do 3.47.x: o skwasm multi-threaded pode travar em frames com muito texto, então force o modo single-threaded até o 3.48 chegar ao stable.

“Renderizador” é uma palavra um pouco enganosa aqui, porque você não escolhe CanvasKit ou skwasm isoladamente. Desde que o Flutter 3.29 removeu o renderizador HTML e a flag --web-renderer, o renderizador decorre do alvo de compilação. A saída do dart2js sempre roda no CanvasKit, e a saída do dart2wasm sempre roda no skwasm. A ferramenta garante isso: flutter build web --wasm --dart-define=FLUTTER_WEB_USE_SKIA=true --dart-define=FLUTTER_WEB_USE_SKWASM=false encerra com Do not attempt to set a web renderer when using "--wasm". Então a pergunta real é “build JavaScript ou build Wasm”, e a resposta decide qual Skia roda por baixo.

A matriz de recursos

Propriedade (Flutter 3.47.4)CanvasKitskwasm
Alvo de compilaçãodart2jsdart2wasm (requer WasmGC)
Comando de buildflutter build webflutter build web --wasm (também gera o build CanvasKit)
Navegadores que o carregam por padrãoTodosSomente Blink (Chrome, Edge, Opera, Chrome no Android)
Download do engine, brotli1.54 MB (variante Chromium), 2.26 MB (variante completa)1.21 MB (skwasm.wasm), 1.86 MB (skwasm_heavy.wasm)
Rasteriza emThread principalUm Web Worker quando a página tem isolamento cross-origin
Headers necessários para o melhor modoNenhumCross-Origin-Opener-Policy + Cross-Origin-Embedder-Policy
dart:html, package:js no grafoTudo bemErro de compilação
Depuração com flutter run -d chromeDevTools completo, hot reload com estado (DDC)Sem service protocol, hot reload vira restart
Carregamento adiado (deferred)SimDesligado por padrão, flag experimental prevista para 3.50
Problema conhecido no canal stableNenhum bloqueanteCrash multi-threaded com troca de texto, #190039

Duas linhas merecem uma nota. A linha “Somente Blink” não tem a ver com suporte a WasmGC: Firefox e Safari já validam WasmGC hoje. O loader do Flutter os mantém fora do skwasm com uma allowlist fixa no código em browser_environment.js (blink: true, gecko: false, webkit: false). O motivo é que o skwasm multi-threaded entrega os frames do worker para a página com OffscreenCanvas.transferToImageBitmap, que é lento nos dois engines. Os bugs de acompanhamento, Mozilla 1788206 e WebKit 267291, ainda estavam como NEW em setembro de 2026.

A linha de depuração vem direto de resident_web_runner.dart no 3.47.4: supportsServiceProtocol é !debuggingOptions.webUseWasm && isRunningDebug && ..., e reloadIsRestart retorna true sempre que webUseWasm está definido. Por isso o desenvolvimento do dia a dia continua no caminho JavaScript, mesmo para equipes que publicam Wasm.

O que cada build realmente baixa

Um build --wasm grava os dois pipelines em build/web, e flutter_bootstrap.js carrega um buildConfig que os lista em ordem de prioridade:

// flutter build web --wasm, Flutter 3.47.4
"builds": [
  {"compileTarget": "dart2wasm", "renderer": "skwasm", "mainWasmPath": "main.dart.wasm", "jsSupportRuntimePath": "main.dart.mjs"},
  {"compileTarget": "dart2js", "renderer": "canvaskit", "mainJsPath": "main.dart.js"}
]

O loader pega a primeira entrada compatível. Dentro de cada renderizador, ele então escolhe uma variante com base nos recursos do navegador. canvaskit_loader.js carrega canvaskit/chromium/canvaskit.wasm quando o navegador tem tanto ImageDecoder quanto Intl.v8BreakIterator. Essa variante deixa os codecs de imagem e os dados de ICU a cargo do navegador, e o Flutter 3.47.0 removeu dela os codecs restantes (#178133). Todo o resto recebe o canvaskit.wasm completo. skwasm_loader.js tem a mesma divisão: skwasm.wasm no Chromium, e o skwasm_heavy.wasm, maior, em qualquer lugar onde essas duas APIs faltem. Na prática, você só vê skwasm_heavy se sobrescrever a allowlist para colocar Firefox ou Safari no Wasm.

Medi quanto custa uma primeira visita em cada caminho, usando o build de release do app de benchmark descrito abaixo (um app Material de cerca de 180 linhas). Os tamanhos são brotli -q 11 e gzip -9 dos arquivos que cada caminho busca. flutter.js, flutter_bootstrap.js, fontes e assets são iguais em todos os caminhos, então ficaram de fora:

Caminho (Flutter 3.47.4)Código do appJS + Wasm do renderizadorTotal brotliTotal gzip
CanvasKit, variante Chromiummain.dart.js 413 KB1,564 KB1,977 KB2,612 KB
CanvasKit, variante completamain.dart.js 413 KB2,281 KB2,695 KB3,465 KB
skwasmmain.dart.wasm 416 KB + .mjs 6 KB1,225 KB1,647 KB2,083 KB

O código do app empata nesse tamanho: 1,42 MB de Wasm bruto e 1,79 MB de JavaScript minificado bruto comprimem para quase o mesmo tamanho em brotli. A economia vem do engine, já que skwasm.wasm é cerca de 330 KB menor que o CanvasKit Chromium. Uma ressalva para apps grandes: dart2wasm não divide imports adiados por padrão, então um app que depende de deferred as para manter pequeno o primeiro carregamento pode ver o caminho Wasm perder essa vantagem.

O benchmark

O tamanho do download é só metade da história. A outra metade é o tempo de frame, então renderizei as mesmas cenas com cada configuração.

Ambiente. Apple M4, 16 GB de RAM, macOS 26. Google Chrome 153.0.8010.48 iniciado com --headless=new --use-angle=metal (o WebGL reportou ANGLE Metal Renderer: Apple M4), uma janela de 1280x800 com DPR 1 e um perfil novo a cada execução. O app foi compilado em modo release com Flutter 3.47.4 e com 3.48.0-0.5.pre, usando flutter build web --wasm --no-web-resources-cdn, e servido a partir de localhost com Cache-Control: no-store. Uma porta enviava Cross-Origin-Opener-Policy: same-origin e Cross-Origin-Embedder-Policy: require-corp, e outra não enviava nenhum dos dois.

Metodologia. Um único build --wasm serviu todas as configurações. Um flutter_bootstrap.js personalizado lia o renderizador da query string, então as execuções com CanvasKit usaram exatamente o mesmo fallback main.dart.js que usuários reais do Firefox baixam:

// web/flutter_bootstrap.js, Flutter 3.47.4
{{flutter_js}}
{{flutter_build_config}}
const q = new URLSearchParams(location.search);
const config = {suppressMultithreadingWarning: true};
if (q.get('renderer')) config.renderer = q.get('renderer');   // 'skwasm' or 'canvaskit'
if (q.get('st')) config.forceSingleThreadedSkwasm = true;
if (q.get('variant')) config.canvasKitVariant = q.get('variant'); // 'full' to skip the Chromium variant
_flutter.loader.load({config});

Dentro do app, SchedulerBinding.instance.addTimingsCallback coletou FrameTimings por 10 segundos após um aquecimento de 3 segundos. Na web, eles são registrados pelo FrameTimingRecorder do engine em torno de cada chamada draw do rasterizador. “Fps apresentados” é o número de timings (frames que terminaram de rasterizar) por segundo, e cada célula é a mediana de 3 execuções (5 para skwasm multi-threaded no 3.48). No 3.48.0-0.5.pre, CanvasKit (24,0 fps) e skwasm single-threaded (32,3 fps) ficaram a menos de 2% dos seus números no 3.47.4, então as tabelas mostram o 3.47.4 sempre que ele rodou sem problemas. A cena “tiles” tem 600 Containers girando com gradiente, cantos arredondados, um BoxShadow e um Text cujo conteúdo muda a cada frame. A cena “paths” é um CustomPainter desenhando o contorno de 400 paths animados de 40 segmentos.

Cena tiles (pesada):

ConfiguraçãoFps apresentadosBuild p50Raster p50Frame span p90Primeiro frame
CanvasKit, variante Chromium (3.47.4)24.024.2 ms17.1 ms44.0 ms285 ms
CanvasKit, variante completa (3.47.4)24.323.7 ms17.2 ms42.9 ms286 ms
skwasm, single-threaded (3.47.4)32.713.6 ms16.0 ms31.4 ms193 ms
skwasm, multi-threaded (3.47.4)travadon/an/an/a245 ms
skwasm, multi-threaded (3.48.0-0.5.pre)39.314.7 ms22.5 ms48.0 ms238 ms

Cena paths (leve):

Configuração (3.47.4)Fps apresentadosBuild p50Raster p50
CanvasKit, variante Chromium60.43.3 ms3.0 ms
skwasm, single-threaded60.01.2 ms3.9 ms
skwasm, multi-threaded59.91.1 ms4.1 ms

Quatro coisas se destacam:

  1. A maior parte do ganho vem do dart2wasm, não do Skia. O tempo de rasterização é quase idêntico (17,1 ms vs 16,0 ms em tiles, e o CanvasKit é até mais rápido em paths). A fase de build, ou seja, o código Dart de widgets, layout e paint, roda cerca de duas vezes mais rápido quando compilado para WasmGC. Quanto mais trabalho do framework por frame, maior a diferença.
  2. Multi-threading troca latência por throughput. No 3.48 beta, o build multi-threaded apresentou 22% mais frames que o single-threaded (39,3 vs 32,3 fps), enquanto seu raster p50 subiu para 22,5 ms. A thread de UI compila o próximo frame enquanto o worker ainda rasteriza o anterior. Renderer.renderScene mantém apenas a cena pendente mais recente e descarta o resto. O resultado é mais frames no total e um intervalo maior por frame.
  3. Cenas leves ficam limitadas ao vsync de qualquer jeito. Se o seu app é feito de formulários e listas, você não vai notar a diferença de renderizador na taxa de frames. Vai notar a diferença de download e de inicialização.
  4. O primeiro frame em localhost favorece o skwasm single-threaded em cerca de 90 ms (193 ms vs 285 ms; iniciar o worker de renderização devolve parte disso no modo multi-threaded). Sem a rede no meio, essa diferença é custo de compilação e instanciação. Em uma conexão real, a diferença de 330 KB em brotli se soma a ela.

Trate os números absolutos como específicos de um M4 rodando Metal. As proporções é que se transferem.

Quando escolher skwasm

Quando escolher CanvasKit

A pegadinha que escolhe por você no 3.47.x

Na cena tiles, o skwasm multi-threaded no Flutter 3.47.4 travou em 6 de 7 execuções. Em quatro delas, requestAnimationFrame continuou disparando e o framework continuou compilando frames a 60 fps, mas nenhum FrameTiming chegou após os primeiros frames, então nada novo chegou à tela. Nas outras duas, a página parou totalmente de executar timers do Dart. O console do Chrome mostrou Uncaught RuntimeError: null function e table index is out of bounds vindos de skwasm.wasm em algumas execuções, e absolutamente nada em outras. A mesma cena em single-threaded, e a cena paths sem texto em multi-threaded, rodaram sem problemas todas as vezes.

Isso bate com flutter/flutter#190039. Segundo a descrição da correção, o skwasm multi-threaded é compilado com -sWASM_WORKERS mas sem -pthread, então ele linka as bibliotecas de sistema single-threaded do emscripten, onde os mutexes não fazem nada. O layout de texto na thread principal e o worker de raster passam então a compartilhar o SkStrikeCache global do Skia e corrompem o heap quando o texto muda a cada frame. A correção, PR #190048 (“Use thread local strike caches in skwasm”), foi mesclada em 2026-08-05 e faz parte do 3.48.0-0.5.pre. Com o mesmo app recompilado nesse beta, 5 de 5 execuções ficaram estáveis, sem RuntimeError. Um pedido de cherry-pick para o stable (#192115) foi fechado sem merge em 2026-09-01, e nenhuma versão 3.47.x até a 3.47.4 traz a correção.

Até você estar no 3.48 stable, mantenha o build Wasm e desligue as threads em web/flutter_bootstrap.js:

// web/flutter_bootstrap.js, Flutter 3.47.x: avoid #190039
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
  config: {
    forceSingleThreadedSkwasm: true,
    suppressMultithreadingWarning: true,
  },
});

O skwasm single-threaded ainda superou o CanvasKit em 36% nos frames apresentados, então isso custa o bônus do multi-threading, não o ganho do Wasm. Deixar de fora os headers COOP/COEP tem o mesmo efeito, mas a flag de configuração é mais fácil de reverter depois.

Vale conhecer duas saídas de emergência na mesma configuração. renderer: 'canvaskit' faz o loader pular a entrada Wasm e carregar o build dart2js. Nas minhas execuções no 3.47.4 isso funcionou a partir de um build --wasm e reportou dart.tool.dart2wasm == false, então um seletor via query string como o de cima vira um kill switch de produção. E verboseBuildSelection: true (novo no 3.47.0) registra por que cada build candidato foi ignorado, que é o jeito mais rápido de responder “por que esse usuário está no CanvasKit”.

A recomendação, de novo

Compile com flutter build web --wasm e deixe o loader entregar skwasm ao Chromium e CanvasKit a todos os outros. No 3.47.x, adicione forceSingleThreadedSkwasm: true e remova quando migrar para o 3.48 stable com os headers COOP/COEP configurados. Volte para um build CanvasKit simples apenas quando uma dependência bloquear o dart2wasm. Os engines rasterizam praticamente na mesma velocidade. O que você está escolhendo de verdade é o dart2wasm para o seu próprio código Dart, e em 2026 essa é a opção mais rápida e menor onde quer que o navegador permita.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar