Replacing Node Buffer with Uint8Array at the Edge
A line like Buffer.from(data, 'base64') or Buffer.concat(chunks) throws Buffer is not defined at the edge, or your bundler silently drops it and you get garbled bytes in production. Buffer is a Node.js global with no presence in a V8 isolate. The edge gives you Uint8Array, TextEncoder/TextDecoder, and atob/btoa instead — together they cover everything Buffer did, without the global.
This guide is part of Polyfill Strategies for Node.js APIs at the Edge. It replaces the four Buffer operations that show up most — base64, hex, concat, and UTF-8 conversion — with edge-native equivalents, and flags the byte-level traps that produce silent corruption.
Root cause: Buffer is a Node global, not a Web API
Buffer is a Node.js subclass of Uint8Array with extra methods (toString('base64'), Buffer.concat, readUInt32BE) and a global presence the edge does not provide. The edge runtime exposes only the standard primitives:
Uint8Arrayfor raw bytes — the same backing storeBufferextends, minus the helpers.TextEncoder/TextDecoderfor UTF-8 ⇄ bytes, replacingBuffer.from(str)andbuf.toString('utf-8').atob/btoafor base64, replacingBuffer.from(b64, 'base64')andbuf.toString('base64').
The subtlety that causes bugs: btoa/atob operate on binary strings (one character per byte), not on Unicode text and not directly on typed arrays. You must convert between Uint8Array and binary string carefully, or non-ASCII data corrupts. The helpers below get that boundary right.
Step 1: Replace Buffer.from(str) and toString with TextEncoder/TextDecoder
UTF-8 text ⇄ bytes is the most common conversion. TextEncoder always produces UTF-8, matching Buffer’s default.
// text.ts — replaces Buffer.from(str) and buf.toString('utf-8')
export function strToBytes(str: string): Uint8Array {
return new TextEncoder().encode(str); // always UTF-8
}
export function bytesToStr(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes); // UTF-8 by default
}
TextDecoder handles multi-byte characters correctly, so emoji and non-Latin scripts round-trip without corruption.
Step 2: Replace base64 with atob/btoa via a binary-string bridge
btoa and atob work on binary strings, not typed arrays. Bridge through a one-char-per-byte string. This is where naive code corrupts non-ASCII data, so use these helpers verbatim.
// base64.ts — replaces Buffer base64 round-trips
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
// Chunk to avoid call-stack limits on large inputs via String.fromCharCode(...spread)
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
export function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// base64url helpers (JWTs, URL-safe tokens)
export function base64UrlToBytes(s: string): Uint8Array {
return base64ToBytes(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '='));
}
The chunking in bytesToBase64 matters: spreading a multi-megabyte array into String.fromCharCode(...) overflows the call stack. Chunking at 32 KB avoids it.
Step 3: Replace Buffer.concat with a Uint8Array set loop
Buffer.concat joins byte arrays. The edge equivalent allocates the total length once and copies each part in with set.
// concat.ts — replaces Buffer.concat(chunks)
export function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, c) => sum + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
Allocating once and copying is the efficient pattern; repeatedly spreading arrays ([...a, ...b]) reallocates on every join and is far slower for many chunks.
Step 4: Replace toString(‘hex’) with a byte map
Hex encoding (for signatures, digests) is a simple per-byte map. Both directions below.
// hex.ts — replaces buf.toString('hex') and Buffer.from(hex, 'hex')
export function bytesToHex(bytes: Uint8Array): string {
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('hex string must have even length');
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
padStart(2, '0') is essential — without it, a byte like 0x05 encodes as "5" and the hex string desynchronizes.
Configuration
If a dependency reaches for the Buffer global, alias it to a shim at build time rather than injecting a heavy polyfill that inflates the bundle.
// wrangler.jsonc — Cloudflare Workers
{
"name": "buffer-free-edge",
"main": "src/worker.ts",
"compatibility_date": "2026-01-01",
"alias": {
"buffer": "./src/shims/buffer.ts"
}
}
// esbuild equivalent (Vercel / generic)
// build.ts
import { build } from 'esbuild';
await build({
entryPoints: ['src/worker.ts'],
bundle: true,
format: 'esm',
// fail the build if a raw Buffer global sneaks in
define: { 'Buffer': 'undefined' },
});
Local vs production divergence
| Concern | Local dev | Production |
|---|---|---|
Buffer global |
present under Node dev server | Buffer is not defined at the edge |
Buffer.from(b64) |
works | throws — use base64ToBytes |
| Large base64 spread | may pass on small inputs | stack overflow without chunking |
Hex without padStart |
“works” on bytes ≥ 16 | corrupts on bytes < 16 in any input |
Non-ASCII through btoa |
silently wrong | silently wrong — bridge via UTF-8 bytes |
| Bundle weight | polyfill tolerated | bloats the 1 MB budget — alias a shim |
The trap: code that uses Buffer.from(b64, 'base64') runs under next dev or a Node-shimmed wrangler dev, then throws Buffer is not defined in production. Define Buffer as undefined at build time to surface every usage before deploy.
Validation with Vitest
Round-trip every conversion, including a non-ASCII string and a large array, so corruption cannot slip through.
// bytes.test.ts
import { describe, it, expect } from 'vitest';
import { strToBytes, bytesToStr } from './text';
import { bytesToBase64, base64ToBytes } from './base64';
import { concatBytes } from './concat';
import { bytesToHex, hexToBytes } from './hex';
describe('buffer replacements', () => {
it('round-trips UTF-8 text including emoji', () => {
const s = 'héllo 🌍 世界';
expect(bytesToStr(strToBytes(s))).toBe(s);
});
it('round-trips base64 for binary bytes', () => {
const bytes = new Uint8Array([0, 1, 250, 255, 128]);
expect([...base64ToBytes(bytesToBase64(bytes))]).toEqual([...bytes]);
});
it('concatenates chunks in order', () => {
const out = concatBytes([new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5])]);
expect([...out]).toEqual([1, 2, 3, 4, 5]);
});
it('round-trips hex with leading-zero bytes', () => {
const bytes = new Uint8Array([0x05, 0x00, 0xff]);
expect(bytesToHex(bytes)).toBe('0500ff');
expect([...hexToBytes('0500ff')]).toEqual([...bytes]);
});
});
Named pitfalls
- Passing a
Uint8Arraystraight tobtoa.btoaexpects a binary string; a typed array stringifies wrong. Fix: bridge viaString.fromCharCodeper byte. - Spreading a huge array into
String.fromCharCode(...). Overflows the call stack on large inputs. Fix: chunk at ~32 KB as inbytesToBase64. - Hex without
padStart(2, '0'). Bytes below0x10lose a digit and desync the string. Fix: always pad each byte to two hex chars. - Running non-ASCII text through
btoadirectly. Corrupts multi-byte characters. Fix: encode to UTF-8 bytes withTextEncoderfirst, then base64 the bytes. - Bundling a full
bufferpolyfill. Adds tens of KB to the 1 MB budget. Fix: alias to a minimal shim and replace the actual calls.
Production deployment checklist
- No
Bufferglobal usage; build definesBufferasundefined - Base64 uses the chunked binary-string bridge, not a raw typed-array
- Non-ASCII text encoded with
TextEncoder - Hex encoding pads every byte with
-
Buffer.concatreplaced with an allocate-onceset - base64url inputs normalized before decoding (
-/_ - Any
buffer
Frequently Asked Questions
Why is Buffer undefined at the edge?
Buffer is a Node.js global, not a Web API. Edge runtimes run in a V8 isolate that exposes only standard primitives — Uint8Array, TextEncoder/TextDecoder, and atob/btoa. There is no Buffer global, so referencing it throws Buffer is not defined.
How do I base64-encode bytes without Buffer?
Convert the Uint8Array to a binary string with String.fromCharCode (chunked to avoid stack overflow) and pass it to btoa. To decode, run atob and read each character’s charCodeAt back into a Uint8Array. Do not pass a typed array straight to btoa.
Why does my base64 output corrupt non-ASCII text?
btoa operates on binary strings, not Unicode. Passing UTF-8 text directly mangles multi-byte characters. Encode the string to bytes with TextEncoder first, then base64 the bytes. Reverse with TextDecoder after decoding.
Should I use a Buffer polyfill instead of rewriting?
Prefer rewriting to the native primitives. A full buffer polyfill adds tens of kilobytes to the 1 MB bundle budget. If a third-party dependency insists on the buffer import, alias it to a minimal shim rather than bundling the complete polyfill.