DriversRecommendedOutdated drivers can make a good PC feel brokenScan driver issues before chasing fixes manually.Scan NowOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan Now×
Skip to content
World desk13 min

Writing a Real PNG Compressor in Vanilla JavaScript (No WASM, No Libraries)

A PNG encoder in vanilla JavaScript needs more than a compression call. This tutorial covers the signature, IHDR, scanline filters, the zlib stream, CRC-32 and Adler-32, and how to verify the output.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The 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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

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.

  1. 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.
  2. Run an independent inspector on the saved file. The command-line file tool reports the dimensions and colour type. The PNGcheck tool, run as pngcheck -v out.png, lists each chunk and flags CRC and zlib errors.
  3. 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.
  4. 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.
  5. 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 CompressionStream and 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.

Primary references

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
PC Slower Than It Used to Be?Free scan - under a minute

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.