Skip to main content

Guide

node-canvas vs @napi-rs/canvas vs skia-canvas 2026

Compare node-canvas, @napi-rs/canvas, and skia-canvas for server-side canvas rendering in Node.js. OG image generation, performance, native dependencies.

·PkgPulse Team·
0

TL;DR

node-canvas is the original server-side canvas — uses Cairo graphics library, longest track record, most compatible with the browser Canvas API. @napi-rs/canvas is the modern choice — uses Skia (same as Chrome), ships pre-built binaries via N-API so no compile-time native build needed, faster than node-canvas. skia-canvas is another Skia-based option — excellent SVG and text rendering, multi-threading support. For OG image generation in Next.js: use @vercel/og (based on Satori + resvg). For general server-side canvas: @napi-rs/canvas is the best balance of performance and ease of setup.

Key Takeaways

  • node-canvas: ~800K weekly downloads — Cairo-based, requires system libs (libcairo, etc.), well-tested
  • @napi-rs/canvas: ~500K weekly downloads — Skia-based, pre-built binaries, no system deps, faster
  • skia-canvas: ~50K weekly downloads — Skia-based, multi-threaded, excellent SVG support
  • All three implement the browser Canvas 2D API — code is largely portable
  • node-canvas requires brew install cairo (macOS) or apt-get install libcairo2-dev (Linux)
  • @napi-rs/canvas ships pre-built binaries — works on install without native build tools
  • For Next.js OG images specifically: use @vercel/og or satori instead

Server-Side Canvas Use Cases

Common uses:
  - OG image generation (dynamic social media previews)
  - Thumbnail/preview generation
  - Chart/graph rendering to PNG
  - Certificate or badge generation
  - Watermarking images
  - Dynamic banner creation for social platforms
  - Server-side chart rendering (Chart.js, D3)

node-canvas

node-canvas — the original, Cairo-based server canvas:

Setup

# macOS:
brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman

# Ubuntu/Debian:
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev

# Then install:
npm install canvas

Basic usage

import { createCanvas, loadImage, registerFont } from "canvas"
import fs from "fs"

// Create canvas (same API as browser):
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")

// Background:
ctx.fillStyle = "#1a1a2e"
ctx.fillRect(0, 0, 1200, 630)

// Text:
ctx.fillStyle = "#ffffff"
ctx.font = "bold 72px sans-serif"
ctx.fillText("PkgPulse Report", 80, 200)

ctx.fillStyle = "#FF8800"
ctx.font = "36px sans-serif"
ctx.fillText("react vs vue — 2026 Data", 80, 280)

// Save as PNG:
const buffer = canvas.toBuffer("image/png")
fs.writeFileSync("og-image.png", buffer)

// Or get data URL:
const dataUrl = canvas.toDataURL("image/png")

Custom fonts

import { createCanvas, registerFont } from "canvas"
import path from "path"

// Register fonts before use:
registerFont(path.join(process.cwd(), "fonts/Inter-Bold.ttf"), {
  family: "Inter",
  weight: "bold",
})

registerFont(path.join(process.cwd(), "fonts/Inter-Regular.ttf"), {
  family: "Inter",
  weight: "normal",
})

const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")

ctx.font = "bold 72px Inter"  // Now uses the registered font
ctx.fillText("Custom Font Text", 80, 200)

Draw with images

import { createCanvas, loadImage } from "canvas"

async function generateCard(packageName: string, score: number) {
  const canvas = createCanvas(1200, 630)
  const ctx = canvas.getContext("2d")

  // Gradient background:
  const gradient = ctx.createLinearGradient(0, 0, 1200, 630)
  gradient.addColorStop(0, "#1e1e2e")
  gradient.addColorStop(1, "#2d2d44")
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, 1200, 630)

  // Load and draw logo:
  const logo = await loadImage("public/logo.png")
  ctx.drawImage(logo, 80, 80, 120, 120)

  // Score circle:
  ctx.beginPath()
  ctx.arc(1060, 315, 120, 0, 2 * Math.PI)
  ctx.fillStyle = score >= 80 ? "#22C55E" : score >= 60 ? "#EAB308" : "#EF4444"
  ctx.fill()

  ctx.fillStyle = "#ffffff"
  ctx.font = "bold 72px sans-serif"
  ctx.textAlign = "center"
  ctx.fillText(String(score), 1060, 340)

  ctx.font = "24px sans-serif"
  ctx.fillText("score", 1060, 380)

  return canvas.toBuffer("image/png")
}

@napi-rs/canvas

@napi-rs/canvas — Skia-based, pre-built binaries:

Setup (no native build needed)

# No system dependencies needed — pre-built binaries included:
npm install @napi-rs/canvas
# Works immediately on macOS (Intel + Apple Silicon), Linux (x64, arm64), Windows

Basic usage (same Canvas 2D API)

import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"
import fs from "fs"

// Identical to node-canvas API:
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")

// Gradient background:
const gradient = ctx.createLinearGradient(0, 0, 1200, 0)
gradient.addColorStop(0, "#FF8800")
gradient.addColorStop(1, "#FF4400")
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 1200, 630)

// Text with anti-aliasing (Skia renders text beautifully):
ctx.fillStyle = "#ffffff"
ctx.font = "bold 64px sans-serif"
ctx.fillText("react vs vue", 80, 200)

// Save:
const pngBuffer = await canvas.encode("png")  // Note: async encode
fs.writeFileSync("output.png", pngBuffer)

// JPEG with quality control:
const jpegBuffer = await canvas.encode("jpeg", 90)  // 90% quality

Font handling

import { createCanvas, GlobalFonts } from "@napi-rs/canvas"
import { readFileSync } from "fs"
import { join } from "path"

// Register fonts globally:
GlobalFonts.registerFromPath(join(process.cwd(), "fonts/Inter-Bold.ttf"), "Inter")
GlobalFonts.registerFromPath(join(process.cwd(), "fonts/Inter-Regular.ttf"), "Inter")

// Or register from buffer:
const fontData = readFileSync("fonts/GeistMono-Regular.ttf")
GlobalFonts.register(fontData, "GeistMono")

// Use in canvas:
const canvas = createCanvas(1200, 630)
const ctx = canvas.getContext("2d")
ctx.font = "bold 64px Inter"
ctx.fillText("Using Custom Fonts", 80, 200)

OG image in Next.js API route

// app/api/og/route.ts
// Note: For Next.js, @vercel/og is usually better — this is for custom needs

export const runtime = "nodejs"  // edge runtime doesn't support node-canvas or @napi-rs/canvas

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const title = searchParams.get("title") ?? "PkgPulse"
  const pkg = searchParams.get("pkg") ?? "react"

  const { createCanvas, GlobalFonts } = await import("@napi-rs/canvas")

  const canvas = createCanvas(1200, 630)
  const ctx = canvas.getContext("2d")

  // Draw...
  ctx.fillStyle = "#0f172a"
  ctx.fillRect(0, 0, 1200, 630)
  ctx.fillStyle = "#ffffff"
  ctx.font = "bold 64px sans-serif"
  ctx.fillText(title, 60, 240)

  const buffer = await canvas.encode("png")

  return new Response(buffer, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=3600",
    },
  })
}

skia-canvas

skia-canvas — multi-threaded Skia canvas:

Distinctive features

import { Canvas, loadImage, FontLibrary } from "skia-canvas"

// Multi-threaded rendering — process a batch of images in parallel:
async function generateBatchOGImages(packages: string[]) {
  const canvases = packages.map((pkg) => {
    const canvas = new Canvas(1200, 630)
    const ctx = canvas.getContext("2d")

    ctx.fillStyle = "#1a1a2e"
    ctx.fillRect(0, 0, 1200, 630)

    ctx.fillStyle = "#ffffff"
    ctx.font = "bold 64px sans-serif"
    ctx.fillText(pkg, 80, 315)

    return canvas
  })

  // Render all in parallel (uses thread pool):
  return Promise.all(canvases.map((c) => c.toBuffer("png")))
}

SVG export

import { Canvas } from "skia-canvas"

// skia-canvas can export to SVG directly:
const canvas = new Canvas(800, 400)
const ctx = canvas.getContext("2d")

ctx.fillStyle = "#3B82F6"
ctx.beginPath()
ctx.arc(400, 200, 150, 0, 2 * Math.PI)
ctx.fill()

ctx.fillStyle = "#ffffff"
ctx.font = "bold 48px sans-serif"
ctx.textAlign = "center"
ctx.fillText("React", 400, 215)

// Export as SVG string:
const svg = await canvas.toBuffer("svg")
// Returns SVG markup as Buffer — can be served directly

Feature Comparison

Featurenode-canvas@napi-rs/canvasskia-canvas
Rendering engineCairoSkia (Chrome)Skia
Pre-built binaries
System deps✅ Required
PNG output
JPEG output
SVG output
Custom fonts
Multi-threading
PerformanceModerateFastFast
Browser API compat✅ High✅ High✅ High
TypeScript
Emoji rendering⚠️

When to Use Each

Choose @napi-rs/canvas if:

  • You want the best balance of performance and zero system dependency setup
  • Docker/serverless deployment (no apt-get needed)
  • Skia's text rendering quality is important
  • Most use cases — this is the recommended default in 2026

Choose node-canvas if:

  • You need maximum Cairo API compatibility
  • Existing codebase already uses it and migration isn't worth it
  • You need specific Cairo features not in Skia

Choose skia-canvas if:

  • Processing large batches of images (multi-threading benefit)
  • You specifically need SVG export alongside PNG
  • Maximum text rendering quality

Consider @vercel/og instead for Next.js OG images:

// @vercel/og uses Satori (HTML/CSS → SVG → PNG) — no canvas needed
// Works on Vercel Edge runtime — much simpler for OG images:

// app/opengraph-image.tsx
import { ImageResponse } from "next/og"

export default function OGImage() {
  return new ImageResponse(
    <div style={{ background: "#1a1a2e", width: "1200px", height: "630px" }}>
      <h1 style={{ color: "white", fontSize: "72px" }}>PkgPulse</h1>
    </div>,
    { width: 1200, height: 630 }
  )
}
// JSX → PNG — no canvas API knowledge required

Docker and Serverless Deployment: The Real Difference

The most significant practical difference between these libraries in production is what happens inside a Docker container or serverless function. node-canvas requires system-level C libraries — libcairo, libpango, libjpeg — that are not present in minimal base images like node:alpine. A typical Dockerfile for node-canvas adds RUN apk add --no-cache cairo pango jpeg giflib librsvg pixman and the build-essential equivalent, which increases image size by 40-80MB and introduces a layer that must be rebuilt whenever the system package versions change. On AWS Lambda, you need a custom layer with these native libraries bundled separately.

@napi-rs/canvas sidesteps this entirely. It ships pre-compiled binaries for the target platform inside the npm package itself — separate binaries for linux-x64-gnu, linux-arm64-gnu, darwin-x64, darwin-arm64, and win32-x64. When you npm install @napi-rs/canvas inside a node:20-alpine container, the correct binary is selected and no compilation occurs. The Dockerfile needs no additional system dependencies. This is the primary reason @napi-rs/canvas is the recommended default for new projects in 2026, especially for teams deploying to containerized environments where adding system packages creates operational overhead.

skia-canvas also ships pre-built binaries for major platforms but has a narrower platform support matrix than @napi-rs/canvas. It does not support Alpine Linux (musl libc) out of the box — only glibc-based Linux distributions — which rules it out for teams using node:alpine as their base image.

OG Image Generation: Choosing the Right Tool

For Open Graph image generation specifically — the most common server-side canvas use case in web applications — the choice between these libraries depends on your deployment environment and complexity requirements. If you're using Next.js and can deploy to Vercel, @vercel/og (built on Satori and resvg-js) is almost always the better choice: it renders JSX to an image using React component syntax, runs on the Edge runtime, and costs zero cold-start time compared to Node.js canvas libraries. The limitation is that Satori only supports a subset of CSS and HTML — no position: absolute for complex overlapping layouts, limited font subsetting — so designs with intricate compositing still benefit from a canvas API.

When you need a canvas API for OG images (complex gradients, image compositing, precise pixel control), @napi-rs/canvas gives better text rendering than node-canvas because Skia's text shaping engine handles subpixel antialiasing, kerning, and emoji significantly better than Cairo. For batch generation — creating OG images for hundreds of pages at build time — skia-canvas's multi-threading can parallelize rendering across CPU cores. A build script that generates 500 images with skia-canvas will typically complete faster than the same script with @napi-rs/canvas running sequentially, because skia-canvas's canvas.toBuffer() dispatches to a thread pool rather than blocking the event loop.

Font Registration and Cross-Platform Consistency

All three libraries support custom font registration, but the behavior differs enough to cause production surprises. node-canvas uses Cairo's font matching, which falls back to system fonts when a registered font doesn't match a requested family. On macOS this means you might get a high-quality system font in development, but on a Linux Docker container the fallback is a generic serif — producing different-looking output between environments.

@napi-rs/canvas uses Skia's font system with GlobalFonts.registerFromPath(). If no system fonts are available (common in minimal containers), Skia falls back to a built-in embedded font. The result is consistent across platforms but the fallback font is visually plain. The best practice is to always register all fonts explicitly and never rely on fallbacks in server-side rendering. skia-canvas uses a FontLibrary API with similar semantics to @napi-rs/canvas, but also exposes FontLibrary.families so you can programmatically verify which fonts are available at startup — useful for health checks in containerized deployments.


TypeScript Types and API Compatibility

The Canvas 2D context API is a web standard, and all three libraries implement the same CanvasRenderingContext2D interface — meaning most code written for one library works on another with minimal changes. TypeScript types for node-canvas are provided via @types/node-canvas, which mirrors the browser's CanvasRenderingContext2D type from @types/canvas. @napi-rs/canvas ships its own TypeScript declarations that align closely with the browser's OffscreenCanvas and CanvasRenderingContext2D types. skia-canvas similarly ships its own types that extend the browser canvas API with Skia-specific additions.

The type compatibility means you can write canvas rendering code against the standard browser types and expect it to work with any of the three libraries. Where differences emerge is in non-standard features: @napi-rs/canvas's GlobalFonts API has no browser equivalent, and skia-canvas's canvas.saveAs() async method extends beyond the synchronous canvas.toDataURL() of the standard API. When abstracting canvas operations behind an interface for testability (rendering in a JSDOM test environment using a mock canvas, and in production using the real canvas), sticking to the standard CanvasRenderingContext2D interface keeps the abstraction portable.

Testing Server-Side Canvas Code

Testing canvas rendering code in a Node.js environment requires a different approach than browser testing. Jest and Vitest do not provide a canvas implementation by default — JSDOM's canvas support requires installing canvas (the node-canvas package) as a peer dependency and configuring it in the test environment. Teams using @napi-rs/canvas in production cannot use it directly in tests without a compatible native binary in the test environment, which may differ from the development machine. A common pattern is to abstract the canvas creation behind a factory function that can be swapped for a lightweight mock in tests — asserting on calls to fillRect, drawImage, and fillText rather than the actual pixel output. Snapshot testing of final PNG buffers (comparing against a stored reference image) is effective for regression testing but brittle when rendering engines update their antialiasing or font rendering algorithms across versions.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on node-canvas v2.x, @napi-rs/canvas v0.7.x, and skia-canvas v1.x.

Compare image processing and rendering packages on PkgPulse →

See also: React vs Vue and React vs Svelte, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.