I wanted to run CanvasKit WASM in Cloudflare Workers to generate graphics server-side. Here's what I figured out.
Live Example: CanvasKit WASM Tic-Tac-Toe - An interactive game using CanvasKit WASM, Cloudflare Workers, and classic HTML image maps.
Complete Example: canvaskit-wasm-worker - Full implementation you can follow along with or use directly.
The Problem
CanvasKit WASM doesn't work out of the box in Cloudflare Workers because:
- No file system for WASM loading
- Different WASM instantiation requirements
- Font loading through Workers assets
- Memory management in serverless
WASM Instantiation
The CanvasKit WASM docs, and in fact the typescript types, only show a locateFile property:
CanvasKitInit({
locateFile: (file) => '/node_modules/canvaskit-wasm/bin/' + file,
})
We can't use locateFile in Cloudflare Workers, but if you look closely, you may recognize that this is actually an Emscripten initializer.
It secretly has some other properties too. For Cloudflare Workers, the key is the instantiateWasm callback:
import canvasKitWasm from 'canvaskit-wasm/bin/canvaskit.wasm' // WebAssembly.Module
const CanvasKit = await CanvasKitInit({
instantiateWasm(imports, callback) {
let instance = new WebAssembly.Instance(canvasKitWasm, imports)
callback(instance)
return instance.exports
},
})
Also need to set __dirname to avoid errors:
globalThis.__dirname = undefined
CanvasKit WASM is compiled to target a Node.js environment, so it tries to access the global __dirname during initialization. Setting it to undefined prevents errors in Cloudflare Workers where this variable doesn't exist (even with Node.js compatibility mode enabled).
You may also encounter errors related to path or fs modules. If so, add Node.js compatibility mode to your wrangler.jsonc:
{
"compatibility_flags": ["nodejs_compat"],
}
This enables Node.js APIs in Cloudflare Workers, which helps resolve module-related errors from CanvasKit's Node.js-targeted compilation.
Font Loading
Font assets can be loaded from anywhere. One convenient option is to load them through the Workers static asset system:
// wrangler.jsonc
{
"assets": { "directory": "./public/", "binding": "ASSETS" },
}
// worker.ts
const assetUrl = new URL('/roboto-latin-500-normal.woff', request.url)
const robotoTypeface = await env.ASSETS.fetch(assetUrl)
.then((response) => response.arrayBuffer())
.then((fontData) => CanvasKit.Typeface.MakeTypefaceFromData(fontData))
This would expose your font asset file to the public internet, unless you take extra steps to prevent that. But Cloudflare does not charge bandwidth for static assets and typically fonts don't contain private data so for most people I think this should be acceptable.
Drawing Example
Based on the Skia CanvasKit example:
async function createImage(CanvasKit: CanvasKit, roboto: Typeface) {
let surface = CanvasKit.MakeSurface(300, 300)
const canvas = surface.getCanvas()
const paint = new CanvasKit.Paint()
paint.setColor(CanvasKit.Color(66, 129, 164, 1.0))
paint.setStyle(CanvasKit.PaintStyle.Stroke)
paint.setStrokeWidth(5.0)
canvas.clear(CanvasKit.Color(255, 255, 255, 1.0))
canvas.drawText('Hello Workers!', 10, 280, textPaint, textFont)
surface.flush()
const img = surface.makeImageSnapshot()
const pngBytes = img.encodeToBytes()
// Clean up memory
paint.delete()
surface.dispose()
return pngBytes
}
Memory Management
The CanvasKit docs say to delete objects to "free up memory in the C++ WASM memory block". I'm not exactly sure but it sounds like it may be easy to cause a memory leak.
dpe.delete()
skpath.delete()
textPaint.delete()
paint.delete()
textFont.delete()
surface.dispose()
I wonder if we could use JavaScript's new using declaration to handle this automatically:
function useResource<T extends { delete(): void }>(obj: T) {
obj[Symbol.dispose] = obj.delete
return obj
}
{
using paint = useResource(new CanvasKit.Paint())
// paint automatically deleted when scope ends
}
But I haven't tested this approach yet.
Caching
The CanvasKitInit function itself seems to cost around 50ms of CPU time.
I'm not sure if this will even run on Cloudflare Worker free plans, which have a nominal limit of 10ms CPU time per request.
In any case, it seems wise to cache the result of running CanvasKitInit across requests:
let canvasKitPromise: Promise<CanvasKit> | null = null
if (!canvasKitPromise) {
canvasKitPromise = CanvasKitInit({...})
}
This brought down the CPU time on subsequent requests. This works because Cloudflare Workers reuse the same global scope across requests, so module-level variables persist between invocations.
Practicalities
Size and CPU Time
When I uploaded my example, it showed the following size report:
Total Upload: 7211.12 KiB / gzip: 2860.67 KiB
Workers Free supports workers up to 3MB in size after compression and 10MB in size on Workers Paid. Therefore, canvaskit-wasm should fit just barely on the free plan and with a decent amount of room on the paid plan.
The simple example script above took about 65ms of CPU time on cold start and 15ms on warm start. It's not so bad, all things considered. I imagine the time depends on the resolution, complexity and output file format (png, jpeg, webp).
Consider Alternatives
If you are writing some application for Cloudflare Workers, it would be good to evaluate if you can render on the client size in JavaScript, or perhaps return something like a svg response to the user for their browser to render.
If you really need to render a raster image on the server, it may be good to cache the response or avoid generating too many bespoke images.
Service Bindings Architecture
Given the relative heaviness of CanvasKit, it may be wise to separate the CanvasKit rendering logic into its own worker and use service bindings. This approach would have several benefits:
- Isolation: CanvasKit's memory usage and CPU costs are contained in a dedicated worker
- Reusability: Multiple workers can share the same rendering service
- Cold start optimization: The rendering worker stays warm from multiple callers
Here's how you might structure this:
// Image renderer worker (image-renderer.ts)
import { WorkerEntrypoint } from 'cloudflare:workers'
export default class ImageRenderer extends WorkerEntrypoint {
async renderImage(
width: number,
height: number,
text: string
): Promise<ArrayBuffer> {
const CanvasKit = await CanvasKitInit({
// ...
})
// ... CanvasKit rendering logic ...
const pngBytes = img.encodeToBytes()
return pngBytes.buffer
}
}
Then consume it from your main worker:
// Main worker
export default {
async fetch(request, env) {
const result = await env.IMAGE_RENDERER.renderImage(
300,
300,
'Hello Workers!'
)
return new Response(result, {
headers: { 'content-type': 'image/png' },
})
},
}
This pattern would keep your main application lightweight while offloading the heavy CanvasKit work to a specialized service.
Key Takeaways
- Use
instantiateWasminstead oflocateFilefor CanvasKit in Cloudflare Workers - Cache the CanvasKitInit result - 50ms initialization cost, but 15ms warm requests
- Memory management - manually call
.delete()on CanvasKit objects to prevent WASM memory leaks - Bundle size fits within limits - 2.9MB compressed, well under Workers' 10MB paid tier limit
- Consider client-side rendering first - server-side generation costs 65ms cold start, use sparingly
Related Reading
This post shares similar WASM instantiation patterns with my earlier article on Yoga Layout Engine in Cloudflare Workers. Both posts explore running Emscripten-based WASM libraries in Cloudflare's non-standard WebAssembly environment.
Complete Example
See the full implementation at canvaskit-wasm-worker.