A browser client and a Node.js service should produce the same Base64 for the same bytes. They do not need to use the same API to do it. The important agreement is the byte representation of text, the alphabet, and the treatment of padding and invalid input.
Browser: make the UTF-8 step explicit
The traditional browser functions operate on byte strings. Convert Unicode text with TextEncoder, then encode those bytes. On the return path, reconstruct a byte array and decode UTF-8 explicitly. This example uses a loop to avoid expanding a large array into function arguments, although a production file encoder should still use bounded chunks or an appropriate byte API.
const text = '中文😊';
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
const encoded = btoa(binary);
console.log(encoded); // 5Lit5paH8J+Yig==
const decodedBytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(decodedBytes);
console.log(decoded); // 中文😊
Node.js: keep Buffer at the byte boundary
Buffer.from(text, 'utf8') makes the text encoding visible. Calling toString('base64') then represents those bytes. Decoding to a Buffer preserves binary data; converting immediately to a UTF-8 string is only appropriate when the payload is known to be text.
import { Buffer } from 'node:buffer';
const encoded = Buffer.from('中文😊', 'utf8').toString('base64');
console.log(encoded); // 5Lit5paH8J+Yig==
const bytes = Buffer.from(encoded, 'base64');
console.log(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); // 中文😊
The fatal TextDecoder in the example is deliberate. Ordinary Buffer UTF-8 string conversion can insert replacement characters for invalid input. A successful text display would then conceal that the original bytes could not be represented faithfully.
Agree on validation separately
Node's Base64 decoder accepts some input forms that a stricter application may reject. Do not use a successful Buffer conversion as proof that a protocol's canonical representation was supplied. If exact syntax matters for signatures, cache keys or interoperable serialization, check it before decoding.
For a shared regression test, include ASCII, Chinese, emoji, a zero byte, byte FF, and all lengths around a three-byte boundary. Compare bytes across runtimes. Also test URL-safe output with a fixture that actually contains the substituted characters; an input whose output is only letters cannot prove that the variant option works.
When reading files in Node, keep the returned Buffer rather than applying a text encoding to the file read. In the browser, use File.arrayBuffer(). These small boundary choices avoid most accidental corruption. See Node.js Buffer for Node behavior and MDN: btoa() for the browser byte-string contract.