Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
Yes. You can emit a valid 8-bit RGBA PNG from raw pixel data using only plain JavaScript and standard browser built-ins, with no WebAssembly and no npm packages. The hard part is not calling a compressor. It is producing the exact byte layout a PNG decoder expects: the eight-byte signature, an IHDR chunk, a zlib-wrapped DEFLATE stream carried in IDAT chunks, an IEND chunk, a CRC-32 on every chunk, and a filtered scanline stream underneath it all. A browser compression API can supply the DEFLATE step, but calling it does not create a PNG by itself.
The code in this article is a reference sketch. It has not been executed as part of the article, so check its output with an independent decoder before relying on it.
Scope: what “vanilla” allows here
The phrase “no libraries” leaves one question open: may you use the browser’s own compression API? This tutorial takes the narrower reading that makes the project tractable, and it states that choice up front.
Recommended Free Tools
- Used as built-ins:
CompressionStreamfor DEFLATE (optional, see Step 2),Blob,Response,DataView, and typed arrays. - Written by hand: scanline serialization, the five PNG filters, chunk framing, CRC-32, and the Adler-32 check value that zlib requires.
- Not covered: a DEFLATE encoder. If “no libraries” is meant to include DEFLATE itself, the work grows into LZ77 match finding and Huffman coding, which is a separate project. The stored-block path shown below is the smallest DEFLATE subset that still produces a valid zlib stream.
On the standard: the W3C PNG Specification, Third Edition (a Recommendation dated 24 June 2025), says in its compression section: “Only PNG compression method 0 is defined by this International Standard.” Method 0 is DEFLATE with a sliding window of at most 32768 bytes, carried in zlib format. Everything in this article follows the W3C PNG Specification (Third Edition).
#1 Best Overall
The bytes a decoder needs
A PNG datastream is a fixed signature followed by chunks. Each chunk stores its data length, a four-character type, the data, and a CRC over the type and data.
| Element | Size on disk | Required contents |
|---|---|---|
| Signature | 8 bytes | 89 50 4E 47 0D 0A 1A 0A. The bytes detect 7-bit transmission and line-ending corruption. |
| IHDR | 25 bytes (4 length + 4 type + 13 data + 4 CRC) | Width and height (4 bytes each, big-endian), bit depth, color type, compression method 0, filter method 0, interlace method 0. |
| IDAT | 12 bytes + data length, one or more chunks | Together they carry one zlib datastream. A split can fall at any byte and need not match scanline or DEFLATE block boundaries. |
| IEND | 12 bytes | Length 0, type IEND, CRC AE 42 60 82. |
Two checks protect different things and must not be confused. The chunk CRC-32 covers each chunk’s type and data (not its length) and detects damage to the file. The zlib Adler-32 covers the uncompressed filtered scanline stream and is checked after inflation. You compute both, but for different byte ranges.
Fix the input scope first
Every later step depends on the pixel format, so decide it before writing code. This article implements one path: 8-bit truecolor with alpha, non-interlaced, with no metadata chunks. The table shows what the specification permits, so you can see what is excluded.
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 minutePC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11| Color type | Name | Allowed bit depths | Samples per pixel | Handled here |
|---|---|---|---|---|
| 0 | Grayscale | 1, 2, 4, 8, 16 | 1 | No |
| 2 | Truecolor (RGB) | 8, 16 | 3 | No |
| 3 | Indexed-color (requires PLTE) | 1, 2, 4, 8 | 1 | No |
| 4 | Grayscale with alpha | 8, 16 | 2 | No |
| 6 | Truecolor with alpha (RGBA) | 8, 16 | 4 | Yes, 8-bit only |
Source for the table: the W3C PNG Specification (Third Edition). The implementation below fixes these choices:
Rank #2
- Input is row-major RGBA bytes with the top row first. This matches the
dataarray of a canvasImageDataobject. - Interlace method 1 (Adam7) is excluded. It splits the image into seven reduced passes with their own scanline layout.
- Only IHDR, IDAT, and IEND are written. Ancillary chunks such as gAMA or sRGB are omitted, so decoders apply their own default interpretation of the colors.
- Bit depths below 8 and 16-bit samples are excluded. They change the bytes-per-pixel used by the filters and the row length, which is why the scope is fixed here.
Step 1: Serialize scanlines and filter them
Each scanline in the uncompressed stream is one filter-type byte followed by the row’s bytes. For 8-bit RGBA the row is width × 4 bytes, so the uncompressed stream is height × (1 + width × 4) bytes. The filter’s bytes-per-pixel value (bpp) is 4 for this format.
Filtering is reversible preprocessing, not compression on its own. It replaces each byte with the difference from a prediction, which tends to create more repeated values for DEFLATE to exploit.
The five filter types
| Type | Name | Predictor (a = left byte, b = byte above, c = upper-left byte) |
|---|---|---|
| 0 | None | 0 |
| 1 | Sub | a |
| 2 | Up | b |
| 3 | Average | floor((a + b) / 2) |
| 4 | Paeth | PaethPredictor(a, b, c) |
Arithmetic is modulo 256. Neighbors use the unfiltered bytes. A neighbor outside the image counts as 0. Paeth picks whichever of a, b, or c is closest to a + b − c, with ties broken in that order.
Free tools Windows power users keep installed
One-click scans. No signup required.
Choosing a filter per row
The specification does not require one selection heuristic. A common approach, used in the code below, tries all five filters on each row and keeps the one whose signed bytes have the smallest total magnitude. Start with a fixed filter if you want the simplest first version, then add the per-row choice.
const CRC_TABLE = (() => {
const table = 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);
}
table[n] = c;
}
return table;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
}
return (c ^ 0xFFFFFFFF) >>> 0;
}
function adler32(bytes) {
let a = 1, b = 0;
for (let i = 0; i < bytes.length; i++) {
a = (a + bytes[i]) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
// row: unfiltered bytes of this scanline
// prior: unfiltered bytes of the previous scanline (zeros before the first row)
function filterRow(type, row, prior, bpp) {
const out = new Uint8Array(row.length);
for (let i = 0; i < row.length; i++) {
const a = i >= bpp ? row[i - bpp] : 0;
const b = prior[i];
const c = i >= bpp ? prior[i - bpp] : 0;
let predictor = 0;
if (type === 1) predictor = a;
else if (type === 2) predictor = b;
else if (type === 3) predictor = (a + b) >> 1;
else if (type === 4) predictor = paeth(a, b, c);
out[i] = (row[i] - predictor) & 0xFF;
}
return out;
}
function chooseFilter(row, prior, bpp) {
let best = null, bestType = 0, bestScore = Infinity;
for (let type = 0; type <= 4; type++) {
const f = filterRow(type, row, prior, bpp);
let score = 0;
for (let i = 0; i < f.length; i++) {
score += f[i] < 128 ? f[i] : 256 - f[i]; // magnitude of the signed byte
}
if (score < bestScore) { bestScore = score; bestType = type; best = f; }
}
return { type: bestType, data: best };
}
Step 2: Produce the zlib-wrapped DEFLATE stream
The IDAT data is a zlib datastream: a two-byte header, the DEFLATE data, and a four-byte big-endian Adler-32 of the uncompressed filtered stream (filter bytes included). The LZ77 window of the DEFLATE data must not exceed 32768 bytes. The header 0x78 0x01 is valid for default compression, because 0x7801 is divisible by 31.
Option A: browser DEFLATE with CompressionStream(“deflate”)
MDN documents CompressionStream("deflate") as DEFLATE in zlib format, so the header and trailing check value are already present. Do not add a second wrapper. Pass the filtered stream through a Blob and collect the result with Response. The MDN CompressionStream() constructor page documents the format names and says support varies by format. The constructor throws a TypeError for an unsupported format. Check support before you rely on it:
function canUseBrowserDeflate() {
if (typeof CompressionStream === 'undefined') return false;
try { new CompressionStream('deflate'); return true; }
catch (err) { return false; }
}
async function zlibCompressed(data) {
const stream = new Blob([data]).stream().pipeThrough(new CompressionStream('deflate'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
The MDN Compression Streams API overview describes the wider stream model. Because the call is asynchronous, the encoder below is async.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Option B: stored blocks (valid zlib, no compression)
DEFLATE allows stored blocks that copy bytes verbatim, with a 16-bit length and its one’s complement. This path writes no compression at all. It is useful for proving your container, filter, and checksum code before you trust a compressor, and it does not reduce file size.
Rank #4
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const p of parts) { out.set(p, offset); offset += p.length; }
return out;
}
function zlibStored(data) {
const parts = [Uint8Array.of(0x78, 0x01)]; // CMF, FLG
let pos = 0;
do {
const len = Math.min(65535, data.length - pos);
const last = pos + len >= data.length;
const nlen = ~len & 0xFFFF;
parts.push(Uint8Array.of(
last ? 1 : 0, // BFINAL in bit 0, BTYPE = 00 (stored)
len & 0xFF, len >> 8, // LEN, little-endian
nlen & 0xFF, nlen >> 8 // NLEN, little-endian
));
parts.push(data.subarray(pos, pos + len));
pos += len;
} while (pos < data.length);
const check = new Uint8Array(4);
new DataView(check.buffer).setUint32(0, adler32(data)); // big-endian
parts.push(check);
return concat(...parts);
}
If you choose deflate-raw
MDN documents deflate-raw as DEFLATE without the zlib header and trailing checksum. If you use it, you must prepend the two-byte header and append the big-endian Adler-32 yourself. Choosing deflate avoids that work.
Step 3: Build chunks with CRC-32
A chunk is a four-byte big-endian data length, a four-byte ASCII type, the data, and a four-byte big-endian CRC-32 computed over the type and data. The length is not included in the CRC.
function makeChunk(type, data) {
const out = new Uint8Array(12 + data.length);
const view = new DataView(out.buffer);
view.setUint32(0, data.length); // length: data only, big-endian
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
Assemble the file
The encoder below writes one IHDR, one IDAT holding the whole zlib stream, and one IEND. Splitting IDAT into several chunks is optional and is covered in the trade-offs table.
// width, height: integers; rgba: width * height * 4 bytes, top row first
async function encodePng(width, height, rgba, useBrowserDeflate = true) {
const bpp = 4; // bytes per RGBA8 pixel
const stride = width * bpp; // bytes per row, excluding the filter byte
const raw = new Uint8Array((stride + 1) * height);
let prior = new Uint8Array(stride); // zeros before the first row
for (let y = 0; y < height; y++) {
const row = rgba.subarray(y * stride, (y + 1) * stride);
const { type, data } = chooseFilter(row, prior, bpp);
raw[y * (stride + 1)] = type;
raw.set(data, y * (stride + 1) + 1);
prior = row;
}
const ihdr = new Uint8Array(13);
const view = new DataView(ihdr.buffer);
view.setUint32(0, width);
view.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type 6: truecolor with alpha
ihdr[10] = 0; // compression method 0 (DEFLATE)
ihdr[11] = 0; // filter method 0
ihdr[12] = 0; // interlace method 0 (none)
const idat = useBrowserDeflate ? await zlibCompressed(raw) : zlibStored(raw);
const signature = Uint8Array.of(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
return concat(
signature,
makeChunk('IHDR', ihdr),
makeChunk('IDAT', idat),
makeChunk('IEND', new Uint8Array(0))
);
}
// Canvas source and download:
const ctx = canvas.getContext('2d');
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
const useBrowser = canUseBrowserDeflate();
const png = await encodePng(canvas.width, canvas.height, data, useBrowser);
const url = URL.createObjectURL(new Blob([png], { type: 'image/png' }));
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Validate the output
Validation has to come from outside your own code, because the encoder cannot check itself. Work through these steps in order:
Best Value
- Check the published test values for your helper functions. The CRC-32 of the ASCII string
123456789iscbf43926, and the Adler-32 ofWikipediais11e60398. Runcrc32andadler32on those inputs withnew TextEncoder().encode(...). Also confirm thatcrc32of the bytes ofIENDreturnsae426082. - Encode a tiny image with
useBrowserDeflateset tofalse(the stored-block path). Open the file in a browser and confirm the pixels match your input. Use a 4×4 image with distinct corners and one transparent pixel. - Run a command-line checker such as pngcheck with the
-voption. It lists each chunk and reports CRC errors, which separates container faults from pixel faults. - Repeat the test with
useBrowserDeflateset totrue. Compare the decoded pixels against the input, including alpha, and confirm the file is smaller than the stored-block version on a photographic image.
Trade-offs between the options
The format leaves several decisions to the encoder. The table compares them. No size, speed, or memory measurement is available for this implementation, so the cells describe structure and cost rather than results.
| Decision | Option | What you gain | What it costs |
|---|---|---|---|
| Filtering | One fixed filter for every row | Less code and no per-row trial work | Output size not stated; no measurement is cited for this implementation |
| Filtering | Per-row choice among all five filters | Each row uses the filter with the smallest signed-byte magnitude total | Five trial filter passes per row |
| DEFLATE source | CompressionStream(“deflate”) | Real DEFLATE compression with no encoder code | Asynchronous; format support depends on the runtime |
| DEFLATE source | Stored blocks | Smallest correct zlib code; good for validating the container | No size reduction |
| DEFLATE source | Hand-written DEFLATE encoder | Full control over blocks and matching | LZ77 matching and Huffman coding are a much larger project, not covered here |
| Feature scope | 8-bit RGBA, non-interlaced | One code path with a fixed layout | Other color types, bit depths, and Adam7 are not handled |
| Feature scope | All color types, bit depths, and Adam7 | Complete format coverage | Considerably more code; not covered here |
| Memory | Whole image buffered, one IDAT chunk | Simplest structure | Raw filtered scanlines and compressed output are both held in memory at once |
| Memory | IDAT split into several chunks | Compressed output can be emitted in pieces | Extra chunk framing; split points may fall anywhere in the zlib stream |
Troubleshooting
- The decoder says the file is not a PNG. The first eight bytes must be 89 50 4E 47 0D 0A 1A 0A. A common cause is a string or a text-encoded buffer where a byte array was expected.
- The decoder reports an invalid zlib header or stream. Raw DEFLATE was passed where zlib data was required. Use
deflate, or add the header and Adler-32 yourself. - Two wrappers appear in the stream. A
deflateoutput received a second header and checksum. Use the browser output as the complete IDAT payload. - Chunk CRC errors. The CRC was computed over the length field, or the CRC was written in little-endian order. Compute it over the type and data only, and write it big-endian.
- Adler-32 mismatch. The checksum was taken over the RGBA pixels rather than the filtered stream. Filter bytes must be included.
- The image is sheared diagonally. The row stride is wrong. Each scanline must contribute exactly 1 + width × 4 bytes, with the filter-type byte first.
- Colors are swapped. The source used a different channel order. Canvas
ImageDatais RGBA. - Transparency is lost. Color type 2 was written instead of 6, so the alpha channel was dropped.
- A
TypeErroris thrown by CompressionStream. The runtime does not support the requested format. Use the stored-block path or the feature check shown in Step 2.
> (the body consists of the code, tables, and steps above)
The Bottom Line
For a dependable first version, build the container, the filter pass, and both checksums by hand, use CompressionStream('deflate') for the DEFLATE step where the runtime supports it, and keep the stored-block output as a baseline for checking your decoder. A hand-written DEFLATE encoder is a separate project with its own validation burden.
Quick Recap
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.

