DEV Community

che
che

Posted on AI-assisted

Why your base64-encoded image got bigger

A 40 KB icon becomes a 53 KB data URI the moment you inline it. The page still works, the browser still renders it, but the HTML got bigger for no obvious reason.

That is usually the first time anyone notices Base64 is doing something, and it is also where the two most common misconceptions start: that Base64 compresses data, and that it hides it.

It does neither. Base64 is a transport encoding. Once you see what it actually does, both myths fall apart.

Three bytes in, four characters out

Base64 takes the raw bytes of your input and rewrites them using only 64 characters: A-Z, a-z, 0-9, + and /. That is a deliberately boring alphabet — nothing in it needs escaping inside an HTML attribute, a JSON string or an email header. (+ and / do still need encoding in a URL, which is exactly why the URL-safe variant exists; more on that below.)

The arithmetic is the whole story. Each byte is 8 bits. Base64 regroups the stream into 6-bit chunks, and 6 bits can address exactly 64 characters. Three bytes (24 bits) become four characters (4 × 6 bits).

Three in, four out. That is where the 33% expansion comes from — it is not a bug or a bad implementation, it is the definition.

const input = 'hello';                                  // 5 bytes
const encoded = Buffer.from(input).toString('base64');  // 8 characters

encoded;  // 'aGVsbG8='
Enter fullscreen mode Exit fullscreen mode

The = at the end is padding. When the input length is not a multiple of three, the last group is incomplete, and = fills the gap so the output length stays a multiple of four. One or two =, never more.

Not encryption: there is no key

Base64 has no secret. The alphabet is public, the algorithm is public, and every step is reversible by anyone holding the string. There is nothing to "decrypt" because nothing was encrypted.

This matters in one place more than any other: JWTs. A JWT is three Base64url segments joined by dots, and the middle one is the payload. People regularly assume it is encrypted because it looks like noise.

const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.signature';
const payload = token.split('.')[1];

JSON.parse(Buffer.from(payload, 'base64url').toString());
// { sub: '123', role: 'admin' }
Enter fullscreen mode Exit fullscreen mode

No key was needed. Anyone who intercepts that token can read the payload. JWTs are signed, not encrypted — the signature proves the token was not altered, it does not hide the contents.

If you need to hide something, encode it after you encrypt it:

plaintext -> encrypt (AES-GCM) -> base64 -> transport
Enter fullscreen mode Exit fullscreen mode

The Base64 step here exists only because binary ciphertext cannot travel safely through text channels. The secrecy comes entirely from the encryption step.

Not compression: it makes things bigger

The 33% expansion is measurable and predictable.

const bytes = Buffer.alloc(100000);           // 100 KB of bytes
const encoded = bytes.toString('base64');

encoded.length;   // 133336  (~133 KB)
Enter fullscreen mode Exit fullscreen mode

Compression reduces size by finding repetition. Base64 does the opposite: it takes a dense byte stream and spreads it across a larger set of characters. It cannot reduce entropy, because it does not try.

The practical consequence is that inlining assets as data URIs is a trade-off, not a free win. For a 1 KB SVG icon it can be worth it — you save an HTTP request and the overhead is negligible. For a 500 KB photo it is usually the wrong call: you pay 33% more HTML on every page load, and a large inline URI holds up the parser before anything renders.

The rule of thumb: inline things under a few kilobytes, link to everything else.

Where Base64 is the right tool

It exists because some channels only accept text. Base64 turns arbitrary bytes into something those channels can carry unchanged.

  • Data URIs. src="data:image/png;base64,..." for small icons that should not cost a request.
  • Email attachments (MIME). Binary files are Base64-encoded so they can travel through text-based mail protocols. MIME also wraps the output at 76 characters per line.
  • Binary payloads in JSON. Many APIs accept a file as a Base64 string instead of a multipart body.
  • Base64url. A URL-safe variant that swaps + for - and / for _, and usually drops the padding. JWTs and OAuth parameters use it.
  • Basic auth. Authorization: Basic base64(user:password) — again encoded for transport, not protected.

In every one of those cases, the reason is the same: the data is safe to carry as text, not secret and not smaller.

The trap: btoa does not handle Unicode

Base64 operates on bytes, not characters. That distinction bites JavaScript developers because btoa() predates Unicode-aware APIs and only accepts Latin-1.

btoa('hello');   // 'aGVsbG8='
btoa('héllo');   // 'aOlsbG8='  — Latin-1 bytes, not UTF-8
btoa('€');       // throws: InvalidCharacterError
Enter fullscreen mode Exit fullscreen mode

The second line is the dangerous one. Because é sits inside Latin-1, btoa does not complain. It silently encodes the single Latin-1 byte 0xE9 instead of the two UTF-8 bytes 0xC3 0xA9, so you get valid Base64 that decodes to something different from what you intended. Only characters above U+00FF, like € or 日本, actually throw.

The fix is to convert the string to UTF-8 bytes first:

const toBase64 = (str) =>
  btoa(String.fromCharCode(...new TextEncoder().encode(str)));

const fromBase64 = (b64) =>
  new TextDecoder().decode(
    Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
  );

toBase64('héllo');          // 'aMOpbGxv'
fromBase64('aMOpbGxv');     // 'héllo'
Enter fullscreen mode Exit fullscreen mode

This is also why two people can encode "the same text" and get different results: UTF-8 and UTF-16 produce different bytes, and Base64 faithfully encodes whichever bytes you gave it.

The short version

Base64 is a reversible, keyless, 33%-larger representation of bytes, designed to move binary data through text-only channels. It is not compression and it is not encryption.

Use it when a channel cannot carry bytes. Do not use it to shrink files, and never confuse it with protecting them.

I put together a browser-based Base64 encoder and decoder for the everyday cases — text in, Base64 out, and back again, with the Unicode handling above taken care of. It runs locally, so nothing you paste is uploaded anywhere.

If you have ever wondered whether a hash counts as encryption, I covered that question in a separate post: MD5 is a hash, it cannot be reversed, and it is not a secret in any meaningful sense.

I build small, privacy-first developer tools that run entirely in the browser. Most of them exist because I needed something for five minutes and every alternative wanted an upload or an account first.

Top comments (0)