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
- A current browser:
fetch()ofdata:URLs,Blob,FormDataandcanvas.toBlobare universal. - Knowledge of your Content Security Policy:
fetch("data:β¦")is governed byconnect-src, which often does not listdata:. - An upload endpoint that accepts binary (multipart or a raw
PUT), not only JSON.
What a data URL costs
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.toBlobovertoDataURL.toBlobencodes asynchronously straight to binary;toDataURLencodes synchronously on the main thread and then base64-encodes the result into a string. For a large canvas,toDataURLcan block the page for hundreds of milliseconds.toBlobreturningnull. 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 aSecurityErrorfromtoDataURL. Treatnullas 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-8and whitespace; it is shorter and faster than a manual loop for large payloads.- The
atobfallback. A CSP withconnect-src 'self'blocksfetch("data:β¦")with a CSP violation. The manual decode needs no network permission. Strip whitespace first: some libraries wrap base64 at 76 characters andatobrejects newlines. - Keeping the MIME type. The partβs
Content-Typecomes from the Blob; a Blob without a type is sent asapplication/octet-stream, which some servers reject before validating the bytes. - Deriving the filename from the type. Servers and storage keys look better with
.pngthan withblob. The name is cosmetic β the server must still validate content, as in detecting file type from magic bytes in JavaScript.
Choosing the conversion path
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
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.