Converting Data URLs to Blobs Before Upload

If you control the source, never make a data URL at all β€” use canvas.toBlob() or OffscreenCanvas.convertToBlob(); if a library hands you one, convert it with await (await fetch(dataUrl)).blob() (or decode with atob into a Uint8Array where fetch of data: URLs is blocked by CSP), then upload the Blob as a binary body or FormData part β€” never post the base64 string itself.

Data URLs creep into upload code through side doors: a signature pad that exposes toDataURL(), a cropping library that returns data:image/png;base64,…, a rich-text editor that inlines pasted screenshots, a canvas snapshot someone wrote years ago. Posting them as-is inflates every upload by a third, forces the server to decode base64 before it can validate anything, and routinely hits JSON body limits. Converting to a Blob is one line β€” once you know which line is safe in your environment. This page is part of base64 vs binary encoding in upload fundamentals and browser APIs. The size arithmetic is covered in optimizing payload size for mobile uploads.

When to use this approach

  • A component you do not control produces data: URLs β€” signature pads, croppers, editors, screenshot tools.
  • Legacy code stores images as data URLs in form state and posts them in JSON.
  • You are cleaning up an upload path and want every body to be binary before it leaves the browser.

Prerequisites

  1. A current browser: fetch() of data: URLs, Blob, FormData and canvas.toBlob are universal.
  2. Knowledge of your Content Security Policy: fetch("data:…") is governed by connect-src, which often does not list data:.
  3. An upload endpoint that accepts binary (multipart or a raw PUT), not only JSON.

What a data URL costs

Size and memory of a data URL versus a Blob A 3 megabyte PNG as a Blob is 3 megabytes of binary. As a data URL it is a 4 megabyte string, which in JavaScript's UTF-16 string storage can occupy up to 8 megabytes, and posting it in JSON sends 4 megabytes plus escaping. The server must then decode it back to 3 megabytes before validating. One 3 MB signature-pad PNG, two representations Blob (binary) 3.0 MB data URL on wire 4.0 MB (+33%) data URL in memory up to 8 MB as a UTF-16 string And the server pays again: decode 4 MB of base64 back into 3 MB before it can check the file type. A Blob goes to the network as-is and to the server's validator as the first bytes of the body. Convert once, at the boundary where the data URL enters your code.
The base64 tax is paid three times β€” in memory, on the wire and on the server β€” and the fix is one conversion.

Implementation

/** Preferred: never create the data URL. */
export function canvasToBlob(canvas: HTMLCanvasElement, type = "image/png", quality?: number): Promise<Blob> {
  return new Promise((resolve, reject) =>
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("canvas is empty or tainted"))), type, quality));
}

/** When given a data URL: fast path via fetch, fallback via atob for strict CSPs. */
export async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
  const m = /^data:([^;,]+)?((?:;[^;,=]+=[^;,]+)*)(;base64)?,(.*)$/s.exec(dataUrl);
  if (!m) throw new TypeError("not a data URL");
  const mime = m[1] || "application/octet-stream";

  try {
    const res = await fetch(dataUrl);               // handles base64 and percent-encoding natively
    const blob = await res.blob();
    return blob.type ? blob : new Blob([blob], { type: mime });
  } catch {
    // Blocked by CSP connect-src, or very old engine: decode manually.
    const payload = m[4];
    if (!m[3]) return new Blob([decodeURIComponent(payload)], { type: mime });   // non-base64 text
    const binary = atob(payload.replace(/\s+/g, ""));
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
    return new Blob([bytes], { type: mime });
  }
}

/** Upload the binary with a sensible filename derived from the MIME type. */
export async function uploadDataUrl(endpoint: string, dataUrl: string, baseName: string): Promise<Response> {
  const blob = await dataUrlToBlob(dataUrl);
  const ext = ({ "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/svg+xml": "svg" } as
    Record<string, string>)[blob.type] ?? "bin";
  const form = new FormData();
  form.append("file", blob, `${baseName}.${ext}`);
  return fetch(endpoint, { method: "POST", body: form });
}

// Usage with a signature pad that only offers toDataURL():
const pad = document.querySelector<HTMLCanvasElement>("#signature")!;
document.querySelector("#save")!.addEventListener("click", async () => {
  const blob = await canvasToBlob(pad, "image/png");          // best: skip the data URL entirely
  const form = new FormData();
  form.append("signature", blob, "signature.png");
  const res = await fetch("/api/contracts/881/signature", { method: "POST", body: form });
  console.log(res.status, blob.size, "bytes");
});

Line-by-line on the details that matter

  • canvas.toBlob over toDataURL. toBlob encodes asynchronously straight to binary; toDataURL encodes synchronously on the main thread and then base64-encodes the result into a string. For a large canvas, toDataURL can block the page for hundreds of milliseconds.
  • toBlob returning null. It happens when the canvas has zero width or height, or has been tainted by cross-origin images drawn without CORS. The same canvas would throw a SecurityError from toDataURL. Treat null as an error, not an empty file.
  • fetch(dataUrl) as the conversion. The browser’s own URL parser handles base64, percent-encoding, parameters like ;charset=utf-8 and whitespace; it is shorter and faster than a manual loop for large payloads.
  • The atob fallback. A CSP with connect-src 'self' blocks fetch("data:…") with a CSP violation. The manual decode needs no network permission. Strip whitespace first: some libraries wrap base64 at 76 characters and atob rejects newlines.
  • Keeping the MIME type. The part’s Content-Type comes from the Blob; a Blob without a type is sent as application/octet-stream, which some servers reject before validating the bytes.
  • Deriving the filename from the type. Servers and storage keys look better with .png than with blob. The name is cosmetic β€” the server must still validate content, as in detecting file type from magic bytes in JavaScript.

Choosing the conversion path

Which conversion to use If you own the canvas, call toBlob and never create a data URL. If you receive a data URL, use fetch to convert it unless the Content Security Policy blocks data URLs in connect-src, in which case decode with atob into a Uint8Array. Source decides the path do you own the canvas? yes: canvas.toBlob() no string ever exists no: CSP allows data:? connect-src yes: fetch(dataUrl).blob() no: atob
The best conversion is the one you never need; the rest are one call each.

Finding data URLs already in your codebase

Legacy upload paths rarely announce that they send base64. Three searches find most of them. Grep the frontend for toDataURL(, readAsDataURL( and data:image β€” each is a place where a string representation of a file is created. Grep the backend for base64 decoding in request handlers (Buffer.from(body.image, "base64"), base64.b64decode) β€” each is an endpoint that receives one. And look at your request-size metrics: JSON endpoints whose bodies are routinely hundreds of kilobytes are almost always carrying encoded files.

For each one, move the conversion to the edge of the code that received the data URL, and change the endpoint to accept multipart or a raw binary body. Keep the old JSON field working for a release so old clients do not break, log when it is used, and remove it once the log goes quiet. On the storage side, check that nothing persisted data URLs in database columns; a text column full of base64 images is a common discovery, and moving those into object storage often shrinks the database dramatically.

Handling libraries that only speak data URLs

Some components offer no binary output at all: an older signature pad with only toDataURL(), a WYSIWYG editor that inlines pasted images into its HTML, a charting library whose export returns a string. You cannot always replace them, but you can contain them.

Wrap the component in a small adapter that converts at the boundary and exposes only Blobs to the rest of your code. For editors that inline images, intercept on save: parse the HTML, find <img src="data:…"> elements, convert each to a Blob, upload it, and replace the src with the resulting URL before the document is stored. That keeps base64 out of your database and out of every later API response that returns the document, which is where it does the most damage β€” a single rich-text field can otherwise grow to megabytes and be sent to every client that lists documents.

Where a library can draw to a canvas you provide, prefer that: give it your canvas, then call toBlob yourself. Many export functions are thin wrappers around canvas.toDataURL(), and reaching for the canvas they drew on is often a one-line change that removes the string entirely.

Configuration gotchas

Refused to connect to 'data:image/png;base64,…' because it violates the following Content Security Policy directive: "connect-src 'self'". Your CSP blocks fetch of data URLs. Use the atob fallback (already in the code) rather than adding data: to connect-src.

DOMException: Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded. The payload contains whitespace or URL-safe base64 characters (- and _). Strip whitespace; for URL-safe input, replace - with + and _ with / and pad with = to a multiple of four.

SecurityError: Tainted canvases may not be exported. An image drawn onto the canvas came from another origin without CORS. Load it with crossOrigin = "anonymous" from a server that sends Access-Control-Allow-Origin, or draw it from a Blob you fetched with CORS.

Server rejects the upload as application/octet-stream. The Blob lost its type (for example, new Blob([bytes]) without options). Pass { type } when constructing it, or re-wrap as in the code.

Where the time goes

Main-thread time for exporting a 4000 by 3000 canvas Exporting a 12 megapixel canvas with toDataURL blocks the main thread for about 420 milliseconds, then converting the data URL back to a Blob adds about 60 milliseconds. toBlob encodes off the main thread and blocks for under 10 milliseconds. 12 MP canvas β†’ uploadable bytes (main-thread ms) toDataURL + convert β‰ˆ 420 ms blocked toBlob < 10 ms blocked (encode runs off-thread) Blocked time is what users feel as a frozen button or a janky animation after they press "Save". Total encode work is similar; only toBlob keeps it off the main thread.
The conversion itself is cheap; creating the data URL in the first place is what freezes the page.

Verification

import { strict as assert } from "node:assert";

const png1x1 =
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
const blob = await dataUrlToBlob(png1x1);
assert.equal(blob.type, "image/png");
const head = new Uint8Array(await blob.slice(0, 8).arrayBuffer());
assert.deepEqual([...head], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "PNG signature");

const text = await dataUrlToBlob("data:text/plain;charset=utf-8,hello%20world");
assert.equal(await text.text(), "hello world");
console.log("data URL conversion ok");

On the network, the upload request should now be multipart/form-data (or a binary PUT) with a body roughly the size of the image, not a JSON body a third larger.

Frequently Asked Questions

Is canvas.toBlob supported everywhere?

Yes, in every current browser. OffscreenCanvas.convertToBlob is the promise-based equivalent for workers and offscreen rendering, supported in current Chromium, Firefox and Safari.

What if the API I call only accepts JSON?

Prefer changing the API. If you cannot, send the file separately β€” upload the Blob to storage with a presigned URL and put the resulting key in the JSON. That keeps JSON small and files binary; uploading files through GraphQL APIs applies the same idea to GraphQL.

Should I ever keep data URLs?

For tiny inline assets β€” a 1 KB placeholder in CSS or HTML β€” data URLs avoid a request and are fine. For anything a user uploads, convert to binary at the first opportunity.