How to Convert an Image to Base64 in JavaScript (3 Ways)

2026-09-17 · 1005 words

There are three common ways to turn an image into Base64 in a browser, and they are not interchangeable. Two of them preserve your bytes exactly and one quietly re-encodes the image; one of them cannot be used on a file the user has not chosen; and on large files one of them will blow the call stack if you write it the obvious way.

Here is what each one actually does, and when to reach for it.

Way 1: FileReader, for bytes you must not touch

If you want the exact bytes of the file the user selected — no re-compression, no metadata loss — FileReader.readAsDataURL is the tool. It reads the file, Base64-encodes it, and hands you a complete data URI including the prefix.

function fileToDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);     // "data:image/png;base64,iVBOR..."
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

const uri = await fileToDataUrl(file);
document.querySelector('img').src = uri;

Note what reader.result contains: the whole data URI, prefix included. If your API expects only the payload, split on the first comma — and be aware that the declared type comes from the file's type property, which is derived from the extension and can be wrong. For an API, it is worth verifying the signature against the bytes.

FileReader streams the file rather than materialising it as an array first, so it is the friendliest of the three to memory. It accepts Blob and File objects, which means it also works on data you generated yourself:

const blob = new Blob([json], { type: 'application/json' });
const uri = await fileToDataUrl(blob);   // works on any Blob, not just user files

Way 2: canvas, when you want the pixels rather than the file

canvas.toDataURL() re-encodes the image through the browser's own encoder. That is what you want if you are resizing, cropping, or normalising a format — and exactly what you must avoid if you care about the original file.

async function reencode(file, maxWidth = 1200, quality = 0.85) {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxWidth / bitmap.width);
  const canvas = document.createElement('canvas');
  canvas.width = Math.round(bitmap.width * scale);
  canvas.height = Math.round(bitmap.height * scale);
  canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
  return canvas.toDataURL('image/jpeg', quality);
}

Four consequences worth knowing before you use it:

Alpha matters here too: converting a transparent PNG to image/jpeg fills transparency with black, because JPEG has no alpha channel.

Way 3: raw bytes, when you need the payload without the prefix

Sometimes you want the Base64 payload on its own, or you want to build the data URI yourself so you control the declared type. Read the file as an array buffer and encode it:

async function fileToBase64Payload(file) {
  const buf = new Uint8Array(await file.arrayBuffer());
  return bytesToBase64(buf);
}

The naive implementation of that last step is where people get burned:

// ✗ breaks on large files
btoa(String.fromCharCode(...bytes));

String.fromCharCode(...bytes) spreads every byte into an argument list, and argument lists have a length limit — in practice around 65,535 in most engines. On a 200 KB image you get RangeError: Maximum call stack size exceeded, which reads like a recursion bug and is actually an argument-count limit.

The fix is to process the buffer in chunks:

function bytesToBase64(bytes) {
  const CHUNK = 0x8000;                      // 32 KiB of bytes per call
  let binary = '';
  for (let i = 0; i < bytes.length; i += CHUNK) {
    binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
  }
  return btoa(binary);
}

Two more details that only show up with real data. First, btoa operates on code units, not bytes — feeding it a string that contains anything above 0x7F throws InvalidCharacterError, which is why the byte-to-string step above uses fromCharCode on raw bytes rather than on decoded text. Second, the reverse direction has the same trap in mirror image: Uint8Array.from(atob(s), c => c.charCodeAt(0)) is correct, while treating the result of atob as text and passing it to a Blob corrupts every byte above 0x7F.

Which one to use

The memory cost of the string form

Base64 output is about 1.37 times the size of the input bytes, and in JavaScript that string is stored as UTF-16 — two bytes per character. The multiplication is worth doing once, because it explains why "just convert it in the browser" stops working at a certain file size:

| Input image | Base64 string | String in memory (UTF-16) | |---|---|---| | 1 MB | ~1.37 MB | ~2.7 MB | | 10 MB | ~13.7 MB | ~27 MB | | 100 MB | ~137 MB | ~274 MB |

Add the original buffer, which you are usually still holding, and a 10 MB image costs roughly 37 MB at the moment of conversion. That is survivable on a desktop and uncomfortable on a phone, which is why the encode side of our tool accepts images up to 100 MB for previewing but keeps the generated string out of the DOM — a textarea holding 137 MB of UTF-16 is the specific failure mode that makes mobile browsers kill the tab.

If you must handle very large images, process them as blobs and never build the whole string: upload the Blob directly, or stream it in chunks and encode each chunk separately. Base64 is convenient precisely because it is text, and text is the expensive representation.

About building the string for a specific consumer

The prefix is a claim, and different consumers want different shapes. A CSS url() needs the full data URI in quotes; an <img src> needs the same string; a JSON API usually wants the bare payload plus a separate content type; an email template often wants the payload wrapped at 76 characters per line. Our Image to Base64 tool exists mainly to produce those variants — including URL-safe output, line-wrapped output, and the surrounding quotes — because getting the wrapper wrong is a more common failure than getting the encoding wrong. If you are generating the string in code, write a test that decodes your own output and compares it byte for byte with the input; that single assertion catches every wrapper mistake listed above.

Related reading

Try the Base64 to Image converter →