runlocally

runlocally engineering notes

Encode Base64

How Encode Base64 is built

By Geppetto · · Open Encode Base64 →

Encode Base64 converts text and files to and from Base64 entirely in the browser. This post is about the binary-safety gap in the browser’s native btoa/atob pair that the tool’s conversion engine is built specifically to avoid, and the chunking trick that keeps the same engine working on files far larger than a few kilobytes.

Tech used

Base64: a 64-character alphabet over bytes, not text

Base64 is a byte-to-text encoding: it takes raw bytes, 3 at a time (24 bits), splits each group into four 6-bit chunks, and maps each 6-bit value onto one of 64 printable ASCII characters (AZ, az, 09, +, /), padding the final group with = when the input length isn’t a multiple of 3. Every step of that operates on byte values — it has no concept of characters, encodings, or file formats, and no idea whether the bytes it’s fed are a JPEG, a PDF, or a UTF-8 string. That indifference is exactly what makes it usable for arbitrary binary data, and exactly why getting bytes into it correctly is the part worth engineering.

Where btoa/atob break, and the hack that half-fixes it

window.btoa and window.atob are the browser’s native Base64 primitives, but they’re defined over “binary strings” — JS strings where every UTF-16 code unit is assumed to already be a byte value in the 0–255 range. btoa(str) throws InvalidCharacterError on any code point above U+00FF, which means it breaks on Japanese, emoji, accented Latin, and effectively all non-Latin-1 text — confirmed directly in this tool’s test suite (base64Engine.test.ts), which asserts encodeTextToBase64 must not throw on '日本語 한국어 中文 🚀🎉👍', the exact input shape that breaks plain btoa.

The common workaround, btoa(unescape(encodeURIComponent(str))), does work, but the engine’s module doc calls it out by name as “an undocumented abuse of two APIs never meant for this” — unescape/escape are deprecated legacy Latin-1 tools, so the trick relies on behavior those functions were never specified to guarantee.

TextEncoder/TextDecoder as the byte source for text mode

Text encoding here goes through TextEncoder().encode(text) first, converting the string into its actual UTF-8 byte sequence, and that byte sequence — not the string — is what gets Base64’d (TextEncoder/TextDecoder were already covered on this blog in the Format JSON and Hex Viewer posts, so the primitive itself isn’t new here). Decoding reverses it: Base64 → bytes → new TextDecoder('utf-8', { fatal: true }).decode(bytes). The fatal: true flag is the notable choice — it makes decoding throw on invalid UTF-8 instead of silently substituting U+FFFD replacement characters, which is TextDecoder’s non-fatal default and the mode Hex Viewer uses elsewhere in this catalog for arbitrary byte-range previews. Here, a caught failure is turned into a typed result, { ok: false, bytes }, rather than propagating the exception — and the UI responds by offering the raw bytes as a file download instead of rendering a string that looks like text but isn’t.

A chunked byte↔binary-string conversion, underneath both paths

The actual binary-safe primitive both text and file modes funnel through is bytesToBase64(bytes: Uint8Array). It builds a binary string in chunks — String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)) for CHUNK_SIZE = 0x8000 (32,768) — concatenates the chunks, then calls btoa once on the whole binary string. The reason for chunking: spreading an entire large Uint8Array into a single String.fromCharCode(...bytes) call passes one argument per byte, and past a few tens of thousands of bytes that blows the JS engine’s call-stack/argument-count ceiling, throwing in some engines and silently truncating in others. Chunking keeps every individual spread comfortably under that limit regardless of the input’s total size. The reverse direction, base64ToBytes, doesn’t need this treatment — it’s atob(base64) followed by a plain indexed loop (binary.charCodeAt(i)) writing into a Uint8Array, with no spread involved.

The exact same 32,768-byte chunk size and String.fromCharCode pattern shows up in this catalog’s Hex Viewer post, solving a different problem (building a Latin-1 search string) but hitting the identical engine limit — the same ceiling, addressed the same way, in two independent tools.

File mode: File.arrayBuffer() feeding the same byte pipe

File encoding reads the chosen or dropped file with file.arrayBuffer(), wraps the result in a Uint8Array, and passes it straight into the same bytesToBase64 used by text mode — so a file’s binary-safety guarantee is identical to plain text’s, because it’s literally the same function (File.arrayBuffer() was already introduced in the Hex Viewer post’s coverage of the File API). File decoding runs the other way: pasted Base64 plus a filename the user types in, since the original filename can’t be recovered from a Base64 string alone — the download button stays disabled until both a valid-looking Base64 payload and a non-empty filename are present. The reconstructed bytes go out through a small downloadBlob helper (URL.createObjectURL on a Blob, a temporary anchor with a download attribute, then URL.revokeObjectURL) shared by every download path in the tool, text or file.

One verb, two directions, and detecting which one from a paste

The tool is named and framed around a single verb, “encode,” covering both directions — the same one-axis bundling this blog’s Format JSON post described for its own format/minify pair. Pasting text triggers looksLikeBase64, which strips whitespace, checks the remaining length is a multiple of 4, matches it against the Base64 alphabet with optional trailing = padding, and then actually calls atob on it and requires that to succeed too — catching cases like misplaced padding ('ab=c') that pass the regex but aren’t valid Base64. This is a heuristic, not a proof: a short word made entirely of Base64-alphabet characters (the engine’s own comment gives "Java" as the example) will register as “looks like Base64” even though it’s plain text — the tool accepts that ambiguity since a manual mode switch is always available. Detection only evaluates the fragment that was just pasted, once, on the paste event itself; it never re-runs as you keep typing, so a manual switch afterward always sticks.

No Web Worker

Base64 conversion runs synchronously on the main thread, with no Web Worker involved. The engine’s module doc is explicit that this is a considered choice, not an oversight — the risk chunking guards against is the call-stack/argument-count ceiling described above, not main-thread CPU time, since the conversion itself is an allocation-only loop with no codec or WASM step to offload. That’s a deliberate contrast with other tools in the same catalog family that decode formats like HEIC through a genuinely CPU-heavy WASM step in a worker.

Implementation & operational notes

Chunk-boundary correctness is tested against an independent implementation, not just itself. The unit tests round-trip a 32,768×3+12,345-byte pseudo-random buffer — deliberately straddling multiple chunk boundaries — through bytesToBase64/base64ToBytes and compare the output byte-for-byte against Node’s own Buffer.from(bytes).toString('base64'), so the browser-native chunked routine is checked against a completely separate Base64 implementation rather than only round-tripping against itself.

Invalid UTF-8 on decode is a distinct, tested state, not an error path that falls through. Decoding bytes like [0xff, 0xfe, 0x00, 0x01] (0xFF is never a valid UTF-8 lead byte) returns { ok: false, bytes } rather than throwing past the caller, and the UI renders that as a specific “raw bytes” notice with its own download button, distinct from the ordinary decoded-text output.

The 200 MB file-size cap is a soft engineering limit, not part of the tool’s confirmed spec. It exists because a large file has to sit in memory twice at once — the input ArrayBuffer and the roughly 4/3-larger Base64 output string — and that output string then also has to survive being rendered into the page and round-tripped through the clipboard on copy.

Data URIs are a thin wrapper, not a separate code path. buildDataUri(mimeType, base64) produces data:<mime>;base64,<data> (falling back to application/octet-stream when no MIME type is known), and stripDataUriPrefix strips that same prefix back off before decoding or auto-detection — so a pasted data: URI is accepted anywhere plain Base64 is, without a separate detection branch.

Try it / source