Comparing PNG optimizers in Node.js
Choose OptiPNG or a full-color Sharp PNG when decoded pixels must stay unchanged. Try pngquant or Sharp’s palette output when you can trade some color accuracy for fewer bytes. This walkthrough runs all four configurations on the same input and records size, elapsed time, and pixel differences so you can make that choice with your own images.
Tool overview
Pngquant
pngquant converts images to a palette with up to 256 colors, including transparency. Its quality range can refuse a conversion. A palette PNG still uses lossless PNG compression, but choosing its colors can change the source pixels.
Optipng
OptiPNG searches compression settings and
can reduce bit depth or color type without changing the decoded image. Here we use -o7, which
tries more compression combinations than lower levels. Optimization can leave an already compact
image unchanged.
Sharp
Sharp’s PNG options support full-color and
palette output. compressionLevel controls compression effort without quantization;
adaptiveFiltering enables row filtering. Setting quality or effort enables a palette, so omit both for the full-color comparison. A Sharp quality
of 80 and pngquant’s 65-80 are settings for different encoders, not equal measured quality.
Installation
Use Bash, Node.js 24.15.0, Corepack with Yarn 4.12.0, and OptiPNG 7.9.1 on your PATH. Install
OptiPNG from its official downloads; this example calls that executable rather
than an npm wrapper. The commands were tested on Linux. Other platforms can have different native
binaries and timings.
Create a new project from a directory where png-compare does not already exist:
(
mkdir png-compare &&
cd png-compare &&
printf '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}\n' > package.json &&
touch yarn.lock &&
YARN_ENABLE_GLOBAL_CACHE=false YARN_ENABLE_SCRIPTS=true YARN_NODE_LINKER=node-modules \
corepack yarn add --exact sharp@0.35.3 pngquant-bin@9.0.0 pngjs@7.0.0
)
The subshell leaves your current directory intact, and && stops setup after a failed step. The
empty lockfile makes this a separate Yarn project even inside another workspace. The environment
settings give it local dependencies and enable pngquant’s native installation. Keep the resulting
lockfile for repeatable dependency resolution. Run the remaining commands from png-compare.
Save the programs below with their .mts extensions. Node
runs .mts files as ES modules
without a compiler or an inherited tsconfig.json. pngjs supplies the separate PNG decoder used
for the checks.
Compare one PNG
Save this as compare.mts. Use static 8-bit RGB or RGBA PNGs. The script rejects animation,
16-bit inputs, and ICC, sRGB, gamma, chromaticity, or Exif chunks. The check compares stored samples
and alpha; it does not establish color-managed appearance or metadata preservation. Keep your
originals.
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { PNG } from 'pngjs'
import pngquant from 'pngquant-bin'
import sharp from 'sharp'
const execFilePromise = promisify(execFile)
type Pixels = { width: number; height: number; data: Uint8Array }
function decodePng(bytes: Buffer): Pixels {
const image = PNG.sync.read(bytes, { checkCRC: true })
// Hidden RGB is not rendered, and decoders represent tRNS keys differently.
for (let index = 0; index < image.data.length; index += 4) {
if (image.data[index + 3] === 0) image.data.fill(0, index, index + 3)
}
return image
}
function checkInput(bytes: Buffer): void {
if (bytes[24] !== 8 || ![2, 6].includes(bytes[25])) {
throw new Error('Use an 8-bit RGB or RGBA PNG')
}
for (let offset = 8; offset + 12 <= bytes.length;) {
const length = bytes.readUInt32BE(offset)
const type = bytes.toString('ascii', offset + 4, offset + 8)
if (['acTL', 'iCCP', 'gAMA', 'cHRM', 'sRGB', 'eXIf'].includes(type)) {
throw new Error(`Unsupported input chunk: ${type}; use an unprofiled static PNG`)
}
offset += length + 12
}
}
function pixelMetrics(before: Pixels, after: Pixels) {
if (before.width !== after.width || before.height !== after.height) {
throw new Error('Output dimensions differ from the input')
}
let changedPixels = 0
let squaredError = 0
let maxAlphaError = 0
for (let index = 0; index < before.data.length; index += 4) {
const alphaBefore = before.data[index + 3] / 255
const alphaAfter = after.data[index + 3] / 255
maxAlphaError = Math.max(maxAlphaError, Math.abs(alphaBefore - alphaAfter) * 255)
if (before.data.subarray(index, index + 4).some((value, channel) =>
value !== after.data[index + channel])) changedPixels++
for (const background of [0, 255]) {
for (let channel = 0; channel < 3; channel++) {
const a = before.data[index + channel] * alphaBefore + background * (1 - alphaBefore)
const b = after.data[index + channel] * alphaAfter + background * (1 - alphaAfter)
squaredError += (a - b) ** 2
}
}
}
return {
changedPixels,
rmse: Number(Math.sqrt(squaredError / (before.width * before.height * 6)).toFixed(3)),
maxAlphaError: Math.round(maxAlphaError),
}
}
async function optimizeWithPngquant(input: string, output: string, quality: string) {
try {
await execFilePromise(pngquant, [
`--quality=${quality}`, '--strip', '--speed=3', '--output', output, '--', input,
])
return output
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 99) return null
throw new Error('pngquant failed', { cause: error })
}
}
async function optimizeWithOptipng(input: string, output: string) {
await execFilePromise('optipng', ['-o7', '-strip', 'all', '-out', output, '--', input])
return output
}
async function optimizeWithSharp(input: string, output: string, palette: boolean) {
await sharp(input)
.png(palette
? { compressionLevel: 9, adaptiveFiltering: true, palette: true, quality: 80, effort: 10 }
: { compressionLevel: 9, adaptiveFiltering: true, palette: false })
.toFile(output)
return output
}
async function main(): Promise<void> {
const [inputArgument, directoryArgument, quality = '65-80', ...extra] = process.argv.slice(2)
const range = /^(\d{1,3})-(\d{1,3})$/.exec(quality)
if (!inputArgument || !directoryArgument || extra.length || !range ||
Number(range[1]) > Number(range[2]) || Number(range[2]) > 100) {
throw new Error('Usage: node compare.mts INPUT.png NEW_DIRECTORY [MIN-MAX]')
}
const input = resolve(inputArgument)
const directory = resolve(directoryArgument)
const original = await readFile(input)
const before = decodePng(original)
checkInput(original)
const versions = {
node: process.version,
sharp: sharp.versions.sharp,
libvips: sharp.versions.vips,
pngquant: (await execFilePromise(pngquant, ['--version'])).stdout.trim(),
optipng: (await execFilePromise('optipng', ['-version'])).stdout.trim().split('\n')[0],
}
await mkdir(directory)
const jobs = [
{ name: 'pngquant', lossless: false, settings: `quality=${quality}, speed=3`, run:
(output: string) => optimizeWithPngquant(input, output, quality) },
{ name: 'optipng', lossless: true, settings: '-o7 -strip all', run:
(output: string) => optimizeWithOptipng(input, output) },
{ name: 'sharp-full', lossless: true, settings: 'compressionLevel=9, adaptiveFiltering=true, palette=false', run:
(output: string) => optimizeWithSharp(input, output, false) },
{ name: 'sharp-palette', lossless: false, settings: 'quality=80, effort=10, compressionLevel=9, adaptiveFiltering=true', run:
(output: string) => optimizeWithSharp(input, output, true) },
]
const results = []
for (const job of jobs) {
const start = performance.now()
const file = await job.run(join(directory, `${job.name}.png`))
const ms = Number((performance.now() - start).toFixed(1))
if (file === null) {
results.push({ name: job.name, status: 'refused', settings: job.settings, ms })
continue
}
const encoded = await readFile(file)
const metrics = pixelMetrics(before, decodePng(encoded))
if (job.lossless && metrics.changedPixels !== 0) {
throw new Error(`${job.name}: decoded pixels changed`)
}
results.push({ name: job.name, status: 'encoded', file, settings: job.settings,
bytes: encoded.length, ms, ...metrics })
}
const report = {
input: { file: input, bytes: original.length, width: before.width, height: before.height,
sha256: createHash('sha256').update(original).digest('hex') },
versions,
results,
}
const reportFile = join(directory, 'results.json')
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' })
console.table(results)
console.log(`Report: ${reportFile}`)
}
main().catch((error: unknown) => {
console.error(`Comparison failed for ${process.argv[2] ?? '(no input)'}:`,
error instanceof Error ? error.message : error)
process.exitCode = 1
})
Every result goes into a newly created directory; an existing directory is refused without replacing
its files. Invalid input or a missing prerequisite fails before directory creation. A later encoder
or verification failure can leave partial PNGs, but no completed results.json. Only consume a
report after the command exits successfully.
pngquant’s exit code 99 becomes a refused row with no candidate file. It means that conversion was
refused at the requested quality setting, not that the original failed to decode. In particular, a
refusal does not give you a measured score for the output. Other errors fail the comparison.
changedPixels counts differing RGBA pixels after ignoring RGB hidden beneath zero alpha. It must
be zero for the lossless rows. rmse measures RGB error after compositing over black and white, on a
0–255 sample scale; maxAlphaError reports the largest alpha difference. These are useful checks,
but a low average error can conceal a bad edge or banding. Inspect palette images at their intended
display size over both backgrounds before accepting them.
File size comparison
Save this as make-fixtures.mts. It creates three 256 × 256 PNGs: a smooth gradient, four-color line
art, and colored strips with transparent, half-transparent, and opaque pixels. The input encoding
uses a low compression level so the comparison has room to recompress it.
import { mkdir, writeFile } from 'node:fs/promises'
import { PNG } from 'pngjs'
async function main(): Promise<void> {
await mkdir('fixtures')
const size = 256
for (const name of ['gradient', 'line-art', 'alpha']) {
const data = Buffer.alloc(size * size * 4)
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const index = (y * size + x) * 4
const line = x % 32 < 2 || y % 32 < 2
const color = x < 128 ? [30, 110, 220] : [230, 70, 40]
const rgb = name === 'gradient' ? [x, y, Math.floor((x + y) / 2)]
: name === 'line-art' ? (line ? [0, 0, 0] : (y < 128 ? color : [255, 255, 255]))
: color
data.set(rgb, index)
data[index + 3] = name === 'alpha' ? (x < 64 ? 0 : x < 192 ? 128 : 255) : 255
}
}
const bytes = PNG.sync.write({ width: size, height: size, data }, { deflateLevel: 1 })
await writeFile(`fixtures/${name}.png`, bytes, { flag: 'wx' })
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
After saving both programs, run:
node make-fixtures.mts &&
node compare.mts fixtures/gradient.png results-gradient &&
node compare.mts fixtures/line-art.png results-line-art &&
node compare.mts fixtures/alpha.png results-alpha
Each successful comparison prints a table and the path to its results.json, alongside the named
PNG outputs. The generator refuses an existing fixtures directory. To try another image or
setting, reuse compare.mts with a new result directory. For example, this requests pngquant’s
strictest quality setting while leaving the other configurations unchanged:
node compare.mts fixtures/gradient.png results-strict 100-100
One Linux x86-64 run on October 3, 2026 used Node.js 24.15.0, pngquant-bin 9.0.0 supplying pngquant 3.0.3, OptiPNG 7.9.1, Sharp 0.35.3, libvips 8.18.3, and pngjs 7.0.0. pngquant used two OpenMP threads. For the 29,234 B gradient input, the settings in the program produced:
| Configuration | Bytes | Time (ms) | Changed pixels | RMSE |
|---|---|---|---|---|
| pngquant | 18,681 | 32.1 | 65,317 | 6.081 |
| OptiPNG | 679 | 182.6 | 0 | 0 |
| Sharp full-color | 830 | 6.2 | 0 | 0 |
| Sharp palette | 36,104 | 241.3 | 65,314 | 5.164 |
This gradient’s regular pixel values compress well without a palette. Quantization changed colors and produced larger files than the lossless configurations; the Sharp palette was larger than the input too. On the line-art fixture, every configuration preserved the decoded pixels, with OptiPNG producing 195 B and pngquant 198 B. All four also preserved the alpha fixture’s rendered colors and its 0, 128, and 255 alpha values. These small synthetic inputs expose different behaviors; they do not represent a production asset collection.
Times cover one serial encoder call per row, including subprocess startup and file writing. They exclude setup and the independent pixel check. They are local measurements, not a throughput benchmark; repeat with representative assets and record the versions and settings in the report.
Choosing the right tool
For pixel-exact assets such as diagrams, compare OptiPNG with sharp-full and reject any nonzero
changedPixels. A palette may encode a small set of colors exactly too, but check its actual pixels
rather than assuming that every palette conversion loses detail. For gradients and translucent
edges, compare savings with the error columns and your visual inspection.
Keep the original when a candidate is larger, refused, or visibly worse. Use Sharp when resizing or other processing already belongs in the same Node.js pipeline; use OptiPNG when you want to recompress existing PNGs with a pixel-preservation check.
