Yes, you can write a valid PNG from raw RGBA pixels using plain JavaScript, with no WebAssembly and no third-party library. The part that surprises most people is how much a PNG encoder has to produce. The output is a container of length-prefixed chunks, each with a CRC. The pixel data is serialized row by row, with a filter-type byte in front of every row. The filtered bytes are stored as one zlib-wrapped DEFLATE stream that ends with an Adler-32 check. A browser compression call can supply the DEFLATE part, but on its own it produces no PNG. The signature, header, filters, chunk framing and checksums all have to be written by you.
What the encoder has to produce
Before writing any code, it helps to see the file as five layers. Each one has a different owner in a browser-only build.
As an Amazon Associate I earn from qualifying purchases.
| Layer | What it contains | Who writes it in a browser-only build |
|---|---|---|
| Signature | Eight fixed bytes that identify the file as PNG and catch line-ending damage | You |
| IHDR chunk | Width, height, bit depth, colour type, compression method, filter method, interlace method | You |
| Scanlines and filters | One filter-type byte followed by filtered pixel bytes for each row | You |
| zlib-wrapped DEFLATE | A two-byte zlib header, compressed DEFLATE blocks, and a 4-byte Adler-32 trailer | Either CompressionStream with the deflate format, or your own code |
| IDAT and IEND chunks | Length, type, data and CRC-32 for each chunk | You |
The rest of this tutorial works through those layers in the order the bytes appear in the file.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Set the scope before you write code
The title does not settle two things: what “vanilla” allows, and which PNG variants the encoder must handle. Decide both first, because each decision changes the code.
#1 Best Overall
Which built-ins count as vanilla
There are two defensible readings. The first treats standardized browser APIs as part of the platform and allows them for DEFLATE only. The second treats “no libraries” as meaning that DEFLATE itself is also hand-written. The first is a small project. The second is a separate compression project in its own right.
| Reading | Built-in used | You write | Main cost |
|---|---|---|---|
| A: browser DEFLATE | CompressionStream |
Scanline serialization, filters, chunks, CRC-32, and a zlib wrapper if you use deflate-raw |
Depends on the browser supporting the format you choose |
| B: full stack | None | Everything in reading A, plus LZ77 matching and Huffman coding for DEFLATE | A substantially larger codebase that needs its own testing |
This tutorial uses reading A. Whichever you choose, state it in your README so readers know what “no libraries” covers.
Which PNG variants are in scope
The PNG specification defines five colour types, each with its own allowed bit depths. The W3C Recommendation puts it this way: “Only PNG compression method 0 is defined by this International Standard.” (W3C, Portable Network Graphics (PNG) Specification, Third Edition, dated 24 June 2025.) The table shows what this encoder covers.
| Colour type | Allowed bit depths | Handled by this encoder |
|---|---|---|
| 0 Greyscale | 1, 2, 4, 8, 16 | No |
| 2 Truecolour (RGB) | 8, 16 | No |
| 3 Indexed-colour | 1, 2, 4, 8 | No (also needs a PLTE chunk) |
| 4 Greyscale with alpha | 8, 16 | No |
| 6 Truecolour with alpha (RGBA) | 8, 16 | Yes, 8-bit only |
The examples below target colour type 6 at 8 bits, non-interlaced, with no ancillary chunks. Interlace method 1 (Adam7) splits the image into seven reduced passes, each with its own filtered scanlines, and is out of scope here.
Rank #2
Step 1: Serialize pixels as scanlines
PNG stores samples in scanline order: left to right within a row, rows from top to bottom. At 8-bit RGBA each pixel is four whole bytes, so each row is width × 4 bytes and rows need no padding. Padding and bit packing only matter for depths below 8. The byte stride is 4 for this encoder, and that same value is the filter’s bytes-per-pixel, which is what the next step relies on.
Check the input length before you start. A mismatch usually means the canvas size and the buffer size disagree, and that produces a file that looks almost right but shears from row to row.
Step 2: Filter each row
Each row is preceded by a one-byte filter type. Each filtered byte is the raw byte minus a predictor, taken modulo 256. The predictor uses three neighbours of the same channel: a is the byte one pixel to the left, b is the byte directly above, and c is the byte above and to the left. Bytes that fall before the start of a row count as 0. For the first row, the row above counts as all zeros.
| Type | Name | Predictor |
|---|---|---|
| 0 | None | 0 |
| 1 | Sub | a |
| 2 | Up | b |
| 3 | Average | floor((a + b) / 2) |
| 4 | Paeth | Paeth predictor of a, b, c (see code) |
Filtering is lossless. Its purpose is to turn pixel data into runs of small or repeated values that DEFLATE can compress more easily. The specification lets the encoder choose a filter type for each row and does not require a particular selection method. A common approach is to pick, per row, the filter whose output has the smallest sum of absolute values. Start with one fixed filter so you can debug the arithmetic first.
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
function predictor(type, a, b, c) {
switch (type) {
case 0: return 0;
case 1: return a;
case 2: return b;
case 3: return (a + b) >> 1;
case 4: return paeth(a, b, c);
default: throw new Error('unknown filter type ' + type);
}
}
// pixels: Uint8Array of width * height * 4 bytes
// filterFor(y): returns the filter type (0 to 4) for row y
function filterRows(pixels, width, height, filterFor) {
const bpp = 4;
const stride = width * bpp;
const out = new Uint8Array(height * (1 + stride));
const zeroRow = new Uint8Array(stride);
let o = 0;
for (let y = 0; y < height; y++) {
const cur = pixels.subarray(y * stride, (y + 1) * stride);
const prev = y === 0 ? zeroRow : pixels.subarray((y - 1) * stride, y * stride);
const type = filterFor(y);
out[o++] = type;
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? cur[i - bpp] : 0;
const b = prev[i];
const c = i >= bpp ? prev[i - bpp] : 0;
out[o++] = (cur[i] - predictor(type, a, b, c)) & 0xFF;
}
}
return out;
}
Use filterRows(pixels, w, h, () => 1) to apply Sub to every row, or pass a function that returns a different type per row once the fixed version round-trips.
Step 3: Produce the zlib-wrapped DEFLATE stream
PNG compression method 0 is DEFLATE with a sliding window of at most 32768 bytes, carried inside a zlib stream. The zlib header declares the window size, so a header byte of 0x78 signals a 32 KiB window, which matches the PNG limit. Three routes produce a valid stream.
Route 1: CompressionStream with the deflate format
MDN documents CompressionStream('deflate') as DEFLATE in zlib format, and 'deflate-raw' as DEFLATE without the header or trailing checksum (MDN, CompressionStream() constructor; page last modified 22 June 2026). The deflate output is what the IDAT chunk needs, so use it as is. Do not add a second zlib wrapper.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallThe constructor throws a TypeError for an unsupported format, so handle that case explicitly. The constructor also accepts only the format name, so you cannot choose a compression level through it.
Rank #4
async function zlibWithBrowserDeflate(bytes) {
if (typeof CompressionStream !== 'function') {
throw new Error('CompressionStream is not available in this runtime');
}
let cs;
try {
cs = new CompressionStream('deflate'); // zlib format: header plus Adler-32 trailer
} catch (err) {
throw new Error('deflate format unsupported: ' + err.message);
}
const out = new Blob([bytes]).stream().pipeThrough(cs);
return new Uint8Array(await new Response(out).arrayBuffer());
}
Route 2: deflate-raw plus your own zlib wrapper
If you use 'deflate-raw', you must add the two-byte header and the Adler-32 trailer yourself. The header 0x78 0x9C is the common default. The trailer is the Adler-32 of the uncompressed bytes, which here means the filtered scanlines, not the original pixels. It is stored big-endian.
function zlibWrapRaw(rawDeflate, uncompressed) {
return concat([
Uint8Array.of(0x78, 0x9C),
rawDeflate,
u32be(adler32(uncompressed))
]);
}
Route 2 gives you control over the framing, but it does not make the output any more compressed. The one error to avoid is wrapping twice, which means a header followed by a zlib stream that already has one.
Route 3: your own DEFLATE
A hand-written encoder can start with stored blocks. These are the uncompressed block type of the DEFLATE format (RFC 1951), each holding up to 65,535 bytes. Wrapped in a zlib header and Adler-32 trailer, they give a valid PNG with no compression at all: the file is roughly as large as the raw pixels. That is the honest floor for a “real” encoder. Actual compression needs LZ77 string matching within the 32768-byte window and Huffman coding of the resulting literals and matches. Build the stored-block version first, so your framing and checksums are verified before you add a compressor.
Free tools Windows power users keep installed
One-click scans. No signup required.
Step 4: Compute the two checksums
PNG uses two different checks, and mixing them up is one of the most common causes of files that some decoders reject. CRC-32 protects each chunk and covers the chunk type and data, not the length field. Adler-32 is the zlib trailer and covers the uncompressed filtered stream inside the IDAT data.
Best Value
| Check | Where it sits | What it covers | Size and byte order |
|---|---|---|---|
| CRC-32 | Last four bytes of every chunk | Chunk type bytes plus chunk data bytes | 4 bytes, big-endian |
| Adler-32 | Last four bytes of the zlib stream | Uncompressed filtered scanlines | 4 bytes, big-endian |
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
return t;
})();
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function adler32(bytes) {
let a = 1;
let b = 0;
for (let i = 0; i < bytes.length; i++) {
a = (a + bytes[i]) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
Two reference values let you check the CRC routine before you trust any output. The standard CRC-32 of the ASCII string 123456789 is 0xCBF43926. The empty IEND chunk is the 12 bytes 00 00 00 00 49 45 4E 44 AE 42 60 82, so its CRC field must read AE 42 60 82.
Step 5: Build chunks and assemble the file
A PNG datastream starts with the eight-byte signature 89 50 4E 47 0D 0A 1A 0A. Every chunk then has a four-byte big-endian length (the number of data bytes, excluding the length, type and CRC fields), a four-byte ASCII type, the data, and a CRC. IHDR must come first, IEND must come last, and the IDAT chunks sit between them.
The IHDR fields for this encoder
| Offset in data | Length | Field | Value written here |
|---|---|---|---|
| 0 | 4 | Width | Image width, big-endian |
| 4 | 4 | Height | Image height, big-endian |
| 8 | 1 | Bit depth | 8 |
| 9 | 1 | Colour type | 6 (truecolour with alpha) |
| 10 | 1 | Compression method | 0 (DEFLATE) |
| 11 | 1 | Filter method | 0 (the five filter types) |
| 12 | 1 | Interlace method | 0 (no interlace) |
const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
function u32be(n) {
return Uint8Array.of((n >>> 24) & 0xFF, (n >>> 16) & 0xFF, (n >>> 8) & 0xFF, n & 0xFF);
}
function concat(parts) {
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
function makeChunk(type, data) {
const out = new Uint8Array(12 + data.length);
out.set(u32be(data.length), 0); // length counts data only
out.set(new TextEncoder().encode(type), 4); // four ASCII letters
out.set(data, 8);
// CRC covers type plus data, not the length field
out.set(u32be(crc32(out.subarray(4, 8 + data.length))), 8 + data.length);
return out;
}
function makeIHDR(width, height) {
const d = new Uint8Array(13);
d.set(u32be(width), 0);
d.set(u32be(height), 4);
d[8] = 8; // bit depth
d[9] = 6; // colour type: RGBA
d[10] = 0; // compression method
d[11] = 0; // filter method
d[12] = 0; // interlace method: none
return makeChunk('IHDR', d);
}
async function encodePNG(rgba, width, height) {
if (rgba.length !== width * height * 4) {
throw new Error('expected width * height * 4 bytes');
}
const filtered = filterRows(rgba, width, height, () => 1); // Sub on every row
const zdata = await zlibWithBrowserDeflate(filtered);
return concat([
PNG_SIGNATURE,
makeIHDR(width, height),
makeChunk('IDAT', zdata),
makeChunk('IEND', new Uint8Array(0))
]);
}
// Browser usage
const ctx = canvas.getContext('2d');
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const png = await encodePNG(new Uint8Array(img.data), canvas.width, canvas.height);
const blob = new Blob([png], { type: 'image/png' });
This version writes one IDAT chunk holding the whole zlib stream. The specification permits splitting the stream across several IDAT chunks at any byte position, not necessarily at scanline or DEFLATE block boundaries, but those chunks must be consecutive. Splitting is a memory decision rather than a correctness one, and it is covered in the trade-offs below.
Step 6: Verify the output
The code shown here has not been executed by this article, so treat it as a structure to check rather than a finished library. Verify it in this order.
- Read the first eight bytes of the output. They must be
89 50 4E 47 0D 0A 1A 0A, followed by a chunk of length 13 with type IHDR. - Run an independent inspector on the saved file. The command-line
filetool reports the dimensions and colour type. The PNGcheck tool, run aspngcheck -v out.png, lists each chunk and flags CRC and zlib errors. - Decode the file in a browser or other decoder, read the pixels back into an RGBA array, and compare them byte for byte with the input array.
- Force each filter type across a whole test image in turn (
() => 0, then() => 1, and so on up to 4). A bug that appears in only one type points straight at that predictor. - Run the two CRC reference values from the previous section before you trust any output.
When verification fails
| Symptom | Likely cause | What to check |
|---|---|---|
| Decoder reports a CRC error on one chunk | CRC computed over the length field, or over the wrong byte range | Recompute over type plus data only, which is bytes 4 to 8 + length |
| zlib or invalid-header error | Raw DEFLATE placed in IDAT, or a second zlib wrapper added | The first two IDAT data bytes should be a zlib header such as 78 9C, and only one wrapper should exist |
| Image is streaked or sheared | Missing filter-type byte, or a stride that does not match width times 4 | Each row should begin with one filter byte, and the output length should equal height times (1 + width times 4) |
| Pixels off by small amounts in patches | Paeth tie-breaking in a different order, or a missing modulo 256 | Compare against the a, b, c order in the code, and confirm the subtraction is masked with & 0xFF |
| Adler-32 mismatch after you wrap raw DEFLATE | Checksum computed over the original pixels rather than the filtered stream | Compute Adler-32 over the bytes passed into DEFLATE |
Trade-offs in the main design choices
Each choice below changes the output or the code size. None of the cited sources give a compression ratio, encoding speed or browser performance figure for these options, so measure them on your own image set before you draw conclusions.
| Decision | Option | What you gain | What it costs |
|---|---|---|---|
| Filter selection | One fixed filter, such as Sub | Simple and deterministic; easy to debug | Output size may be larger than with per-row selection; the size effect is not measured in the cited sources |
| Filter selection | Per-row heuristic | Each row can use the predictor that suits it | More code, and the choice rule is yours, since the specification does not mandate one |
| DEFLATE source | CompressionStream('deflate') |
No DEFLATE code to write, and zlib framing is handled | Depends on runtime support; no compression level can be set |
| DEFLATE source | Hand-written DEFLATE | Full control and no browser dependency | Large: LZ77 matching within a 32768-byte window, plus Huffman coding; stored blocks are the minimal valid option |
| Feature scope | 8-bit RGBA, non-interlaced | Small and easy to test | Only one colour type and depth; other images need different IHDR values and bytes-per-pixel rules |
| Feature scope | Add greyscale or palette output | Smaller files for suitable images | Adds PLTE handling and packed sub-byte rows, where bytes-per-pixel becomes 1 for depths below 8 |
| Memory | Buffer the whole filtered image and output | Simplest code path, one pass | Holds the raw filtered buffer and the compressed buffer in memory together |
| Memory | Stream IDAT chunks as data is produced | Lower peak buffering | Needs a streaming DEFLATE source; each chunk needs its own length and CRC as it is written |
Where this approach stops
- Colour types other than 6 are not produced: greyscale, RGB without alpha, indexed colour (which also requires PLTE), and greyscale with alpha.
- Bit depths other than 8 are not produced, which also means no packed sub-byte rows.
- Adam7 interlacing is not produced.
- Ancillary chunks such as tRNS, gAMA, pHYs, and colour-profile chunks are not written, so colour and physical metadata are absent from the output.
- Browser support for
CompressionStreamand for each format name varies, so check the MDN compatibility data for the browsers you target at the time you publish. The MDN page quoted above is the reference for the format behaviour described here.
Inside that scope, the encoder produces a file whose structure follows the PNG specification. Confirm it with an independent decoder before you rely on it for real images.
Quick Recap
Primary references
- W3C, Portable Network Graphics (PNG) Specification, Third Edition, Recommendation of 24 June 2025: https://www.w3.org/TR/png/
- MDN Web Docs, CompressionStream: CompressionStream() constructor, last modified 22 June 2026: https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream/CompressionStream
- MDN Web Docs, Compression Streams API: https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API
The Bottom Line
“”
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.




