Sharing Images Rendered from a Canvas

This guide is part of Sharing Files and Media Payloads, which itself is part of Native Device Integration Patterns.

A generated chart, a receipt, a story card, a QR code — anything drawn on a <canvas> can go straight into the native share sheet as an image, without a round trip to a server. The path is short: encode the canvas to a Blob, wrap it in a File, validate, share. Three details decide whether it works in production: the encode must not happen inside the click handler, the canvas must not be tainted, and the format must suit the content.

Feature Detection Gate

Validate against a real file of the type you will actually produce, because support is per payload.

// canvas-share-support.js
export function canShareCanvasImages(type = 'image/png') {
  if (window.isSecureContext !== true) return false;
  if (typeof navigator.share !== 'function') return false;
  if (typeof navigator.canShare !== 'function') return false;

  const probe = new File([new Uint8Array(8)], `probe.${type.split('/')[1]}`, { type });
  try {
    return navigator.canShare({ files: [probe] });
  } catch {
    return false;
  }
}

Use this to decide whether a “Share image” control appears at all; use a second validation on the real file immediately before sharing, as checking file support with navigator.canShare explains.

Step 1 — Encode with toBlob, Not toDataURL

toDataURL returns a base64 string: synchronous, roughly 33% larger than the binary, and still needing conversion before it can become a File. toBlob gives you bytes on a callback.

// canvas-to-file.js
export function canvasToBlob(canvas, type = 'image/png', quality = 0.92) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('canvas encode failed — tainted canvas?'))),
      type,
      quality
    );
  });
}

export async function canvasToFile(canvas, name, type = 'image/png', quality = 0.92) {
  const blob = await canvasToBlob(canvas, type, quality);
  return new File([blob], name, { type, lastModified: Date.now() });
}

A null blob is the browser’s way of reporting a tainted canvas. Rejecting with a descriptive message here saves a long debugging session later, because the symptom otherwise appears as an unexplained failure two functions away.

toDataURL Versus toBlob Two encoding routes from the same canvas. The upper route produces a synchronous base64 string that is about a third larger and must be decoded back to binary before a File can be built. The lower route produces binary directly on a callback and feeds the File constructor without conversion. canvas rendered pixels toDataURL() synchronous · blocks base64 string ~33% larger decode to bytes extra work toBlob(cb, type, q) async · off the hot path Blob (binary) exact bytes new File([blob]) ready to share

Step 2 — Encode Ahead of the Gesture

Encoding a full-resolution canvas takes tens to hundreds of milliseconds. Doing it inside the click handler spends the user-activation window on compression, and Safari answers with NotAllowedError. Encode when the drawing changes instead, and keep the result ready.

// share-card.js
import { canvasToFile } from './canvas-to-file.js';

export function createShareableCard(canvas, { name = 'card.png', type = 'image/png' } = {}) {
  let ready = null;
  let generation = 0;

  async function refresh() {
    const mine = ++generation;
    const file = await canvasToFile(canvas, name, type);
    if (mine === generation) ready = file;    // ignore results from stale renders
  }

  return {
    refresh,
    get file() { return ready; },
    isReady() { return ready !== null; }
  };
}

The generation counter prevents a slow encode from an earlier state overwriting a newer one — a real hazard when the canvas is redrawn on every slider drag.

// wire-share.js
import { createShareableCard } from './share-card.js';

export function wireShare(canvas, button) {
  const card = createShareableCard(canvas, { name: 'quarterly-chart.png' });
  card.refresh();
  canvas.addEventListener('chart:rendered', () => card.refresh());

  button.addEventListener('click', async () => {
    const file = card.file;
    if (!file) return;                                   // still encoding

    if (typeof navigator.canShare !== 'function' || !navigator.canShare({ files: [file] })) {
      downloadFile(file);
      return;
    }

    try {
      await navigator.share({ files: [file], title: 'Quarterly chart' });
    } catch (err) {
      if (err.name !== 'AbortError') downloadFile(file);
    }
  });
}

function downloadFile(file) {
  const url = URL.createObjectURL(file);
  const anchor = Object.assign(document.createElement('a'), { href: url, download: file.name });
  anchor.click();
  URL.revokeObjectURL(url);
}

Note what the handler does not do: no await before the share call, and no encoding. The only asynchronous step is navigator.share itself.

Step 3 — Keep the Canvas Untainted

Drawing a cross-origin image onto a canvas without CORS approval taints it permanently, and toBlob then yields null. This surfaces late — the chart renders perfectly, and only sharing fails.

// load-image.js
export function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';        // must be set BEFORE src
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`image load failed: ${src}`));
    img.src = src;
  });
}

Two rules make this reliable: set crossOrigin before assigning src, and confirm the server actually returns Access-Control-Allow-Origin. Requesting CORS from a server that does not grant it fails the load outright rather than tainting silently — which is the better failure, because you find out immediately.

Step 4 — Choose the Right Format and Size

Format choice changes the payload by an order of magnitude, and payload size is the main reason share targets reject images.

// pick-format.js
export function pickFormat(canvas, { photographic = false } = {}) {
  if (!photographic) return { type: 'image/png', quality: 1, name: 'chart.png' };
  const type = canvasSupports('image/webp') ? 'image/webp' : 'image/jpeg';
  return { type, quality: 0.85, name: `photo.${type.split('/')[1]}` };
}

function canvasSupports(type) {
  const probe = document.createElement('canvas');
  probe.width = probe.height = 1;
  return probe.toDataURL(type).startsWith(`data:${type}`);
}

Charts and diagrams belong in PNG: flat colours compress well losslessly, and JPEG artefacts around text look broken. Photographs belong in JPEG or WebP at around quality 0.85, which typically produces a file small enough that no target objects. If the canvas is rendered at a high device-pixel ratio, consider drawing to an offscreen canvas at a fixed export size rather than sharing a 3× bitmap nobody needs.

Choosing an Export Format Two branches from the canvas content. Flat-colour content such as charts, diagrams and text exports to lossless PNG. Photographic content exports to WebP or JPEG at quality 0.85, producing a file typically ten times smaller and far more likely to be accepted by a share target. photographic content? no yes PNG · lossless · quality ignored charts, diagrams, QR codes, anything containing text WebP (or JPEG) · quality 0.85 photos and gradients — roughly a tenth of the PNG size small payloads are far less likely to hit a target's limit Tainting Happens at Draw Time An image loaded without CORS approval taints the canvas the moment it is drawn, and the failure only surfaces later when toBlob returns null. Setting crossOrigin before assigning src, with a permissive server header, keeps the canvas readable. img without CORS loads fine, looks fine drawImage → tainted silent, no error yet toBlob returns null the share fails, far from the cause crossOrigin first then assign src server allows the origin or the load fails loudly canvas stays readable toBlob yields real bytes

Failure Modes and Recovery

Symptom Cause Minimal fix
toBlob calls back with null Canvas tainted by a cross-origin draw Load images with crossOrigin = 'anonymous' and CORS headers
SecurityError on export Same tainting problem, different engine Serve contributing images with permissive CORS
NotAllowedError on share Encoding ran inside the click handler Encode on change; keep the handler synchronous
Shared image is enormous PNG used for a photograph at 3× DPR Export WebP/JPEG at a fixed size
Receiving app shows a generic file File created without a MIME type Pass an explicit type to the constructor
Memory climbs while browsing Object URLs never revoked URL.revokeObjectURL right after the click

Browser and Platform Caveat

canvas.toBlob is supported across Chrome, Safari, Firefox and Edge, though WebP encoding from a canvas is not universal — the canvasSupports probe above guards it. On iOS, sharing a PNG or JPEG to Photos, Messages or Mail is reliable, while very large bitmaps can be rejected by the target after the sheet opens; keep the exported dimensions near the size the recipient will actually view. Desktop Safari accepts a narrower set of image types than iOS, so validate the real file rather than assuming parity. Firefox has no share implementation at all, which makes the download fallback the primary path for a meaningful share of desktop users rather than an edge case — give it the same design attention as the native branch.

FAQ

Why use toBlob instead of toDataURL?

toDataURL is synchronous and returns base64 text roughly a third larger than the binary, so it blocks the main thread and then needs decoding back into bytes before it can become a File. toBlob hands you the binary directly on a callback, which is both faster and the exact shape the File constructor wants.

Why does toBlob return null or throw a SecurityError?

The canvas is tainted: something cross-origin was drawn onto it without CORS approval, so the browser refuses to let you read the pixels back. Load contributing images with crossOrigin set to anonymous and make sure the server sends a permissive Access-Control-Allow-Origin header.

Should I share a PNG or a JPEG?

PNG for charts, diagrams and anything with text or flat colour, because lossless encoding keeps edges crisp. JPEG or WebP for photographic content, where a quality of around 0.85 typically cuts the payload by an order of magnitude and keeps it comfortably inside what share targets accept.