new TextDecoder().decode(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))) decodes Base64 back to a string, in every browser and in Node 16 and later. atob returns one character per byte, Uint8Array.from turns those characters back into bytes, and TextDecoder reads the bytes as UTF-8.
const base64 = 'Y2Fmw6k=';
new TextDecoder().decode(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)));
// 'café'
The tool below decodes the same Y2Fmw6k= to café, 5 bytes.
The mojibake trap
atob on its own gives the bytes, not the text:
atob('Y2Fmw6k=');
// 'café'
é is two bytes in UTF-8, c3 a9, and atob turns each into its own character: Ã (U+00C3) and © (U+00A9). Plain ASCII comes through unharmed, so tests that only use ASCII text never catch it.
What atob accepts
atob follows the WHATWG’s “forgiving-base64 decode”: it skips ASCII whitespace and doesn’t need the padding, but it only reads the standard alphabet.
atob('Y2Fmw6k'); // 'café' padding left off
atob(' Y2Fm\nw6k= '); // 'café' spaces and line breaks skipped
atob('Y2Fmw6k=='); // throws InvalidCharacterError (too much padding)
atob('PDw_Pz8-Pg'); // throws InvalidCharacterError ("-" and "_" are base64url)
For base64url, such as a JWT segment, replace - with + and _ with / first.
Bytes that aren’t text
By default TextDecoder replaces bytes that aren’t UTF-8 with U+FFFD, so the Latin-1 output of btoa('café') comes back wrong without an error:
const bytes = Uint8Array.from(atob('Y2Fm6Q=='), (c) => c.charCodeAt(0));
new TextDecoder().decode(bytes);
// 'caf�'
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// TypeError: Failed to execute 'decode' on 'TextDecoder': The encoded data was not valid.
Pass { fatal: true } when wrong text is worse than an error. The tool makes the same check and shows such bytes as hex: 63 61 66 e9.
Node.js
Buffer.from('Y2Fmw6k=', 'base64').toString();
// 'café'
Buffer never throws on bad input. It reads both alphabets and skips anything else, so Buffer.from('Y2Fm!!w6k=', 'base64') still gives café, and Buffer.from('not base64 at all', 'base64') returns 10 bytes of noise. If the input comes from users, check it first; the tool rejects Y2Fm!!w6k= and names the ! at position 5.
Recent browsers also have Uint8Array.fromBase64('Y2Fmw6k='); Node 22 doesn’t.