Optimize PNGs in the browser with Oxipng
Browser-side PNG optimization can reduce upload size without sending the image to a server first.
This guide uses @jsquash/oxipng, which runs Oxipng through WebAssembly.
Oxipng is a different optimizer from OptiPNG; the historical page URL retains the earlier name.
Understanding Oxipng
Oxipng rewrites PNG compression and representation without changing decoded pixels. We disable transparent-pixel optimization, since changing invisible RGB values would violate strict pixel equality. File metadata and binary representation may change; lossless pixels do not mean byte-identical files.
We use the package's pinned single-threaded codec inside a dedicated Worker. The UI remains responsive, and cancellation terminates that Worker. This avoids requiring cross-origin isolation or a nested worker pool.
Implementation
Use a new, empty project directory. The setup was tested on Linux with
Node.js 24.15.0 and its bundled npm 11.12.1,
and with Node.js 26.8.1 and npm 12.0.2. These are tested versions; later maintained Node.js 24.x
releases are also suitable. Check node --version and npm --version before saving the files.
Save all four files below in that directory. In package.json:
{
"name": "browser-png-optimizer",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" },
"dependencies": { "@jsquash/oxipng": "2.3.0" },
"devDependencies": { "vite": "7.3.6" }
}
In index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PNG optimizer</title>
</head>
<body>
<h1>PNG optimizer</h1>
<label for="file">PNG file</label>
<input id="file" type="file" accept="image/png">
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Ready.</p>
<a id="download" hidden>Download PNG</a>
<script type="module" src="/main.js"></script>
</body>
</html>
In main.js, one job owns one Worker and one download URL. Selecting a file starts the job; no
image data is uploaded:
const input = document.getElementById('file')
const cancel = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
let worker
let timer
let job = 0
let downloadUrl
function release() {
worker?.terminate()
worker = undefined
clearTimeout(timer)
input.value = ''
input.disabled = false
cancel.disabled = true
}
function clearDownload() {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = undefined
download.hidden = true
download.removeAttribute('href')
}
cancel.addEventListener('click', () => {
job += 1
release()
status.textContent = 'Canceled.'
})
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const current = ++job
release()
clearDownload()
if (file.size === 0 || file.size > 8 * 1024 * 1024) {
status.textContent = 'Choose a PNG no larger than 8 MiB.'
return
}
input.disabled = true
cancel.disabled = false
status.textContent = 'Optimizing PNG.'
const fail = (message) => {
if (current !== job) return
job += 1
release()
status.textContent = message
}
timer = setTimeout(() => fail('Optimization timed out.'), 30_000)
try {
const bytes = await file.arrayBuffer()
if (current !== job) return
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
worker.addEventListener('error', () => fail('PNG optimization failed.'))
worker.addEventListener('messageerror', () => fail('PNG optimization failed.'))
worker.addEventListener('message', ({ data }) => {
if (current !== job) return
if (!data.ok) return fail('Choose a valid, non-animated PNG within the image limits.')
const result = new Blob([data.bytes], { type: 'image/png' })
const output = result.size < file.size ? result : file
downloadUrl = URL.createObjectURL(output)
download.href = downloadUrl
download.download = 'optimized.png'
download.hidden = false
status.textContent = result.size < file.size
? 'Optimized PNG is ready.'
: 'The original PNG is already as small or smaller.'
release()
})
worker.postMessage(bytes, [bytes])
} catch {
fail('PNG optimization failed.')
}
})
window.addEventListener('pagehide', () => {
job += 1
release()
clearDownload()
})
Using Web Workers for better performance
In worker.js, check allocation limits before calling the actual PNG optimizer. The header/chunk
checks are guardrails, not a substitute for the codec's validation. Animated PNGs are rejected so
the pixel budget describes a single image.
import init, { optimise } from '@jsquash/oxipng/codec/pkg/squoosh_oxipng.js'
import wasmUrl from '@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm?url'
function validatePng(buffer) {
const data = new Uint8Array(buffer)
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (data.length < 33 || data.length > 8 * 1024 * 1024 ||
!signature.every((byte, i) => data[i] === byte)) throw new Error('Invalid PNG.')
const view = new DataView(buffer)
if (view.getUint32(8) !== 13 || view.getUint32(12) !== 0x49484452) {
throw new Error('Missing PNG header.')
}
const width = view.getUint32(16)
const height = view.getUint32(20)
if (width === 0 || height === 0 || width > 4096 || height > 4096 ||
width * height > 4_000_000) throw new Error('Image exceeds pixel limit.')
let offset = 8
while (offset + 12 <= data.length) {
const length = view.getUint32(offset)
const type = view.getUint32(offset + 4)
if (length > data.length - offset - 12 || type === 0x6163544c) {
throw new Error('Invalid or animated PNG.')
}
offset += length + 12
if (type === 0x49454e44) {
if (length !== 0 || offset !== data.length) throw new Error('Invalid PNG ending.')
return
}
}
throw new Error('Truncated PNG.')
}
self.addEventListener('message', async ({ data }) => {
try {
if (!(data instanceof ArrayBuffer)) throw new Error('Invalid input.')
validatePng(data)
const response = await fetch(wasmUrl)
if (!response.ok) throw new Error('WASM unavailable.')
await init(await response.arrayBuffer())
const bytes = optimise(new Uint8Array(data), 2, false, false)
self.postMessage({ ok: true, bytes: bytes.buffer }, [bytes.buffer])
} catch {
self.postMessage({ ok: false })
}
})
The low-level optimise call
passes level 2, disables interlacing, and leaves transparent RGB unchanged. The ?url suffix is a
Vite asset import. Vite emits the WASM
file with the application. The codec path is pinned to this package version; verify it when upgrading.
See jSquash’s package documentation
for the high-level API and multithreading options.
Browser compatibility
From the project directory, install the pinned packages and start the app:
npm install && npm run dev
Retain the generated package-lock.json. If installation fails, fix the reported dependency or
network error and repeat this command in the same directory; the saved source files remain in place.
Open Vite’s printed local URL in a browser with module Workers, WebAssembly, File/Blob APIs, and
object URLs. The complete workflow was tested in Chromium 145 on Linux; other browser engines and
mobile devices need their own checks.
Select a file with PNG file. The status changes to
Optimized PNG is ready. Click
Download PNG to save optimized.png. If the candidate is not
smaller, you will see
The original PNG is already as small or smaller.
The download then contains the original bytes.
Stop the development server with Ctrl+C. Build and test the same file-input/download flow against the production assets:
npm run build && npm run preview
Open the preview URL printed by Vite and stop that server with Ctrl+C when finished. Vite preview serves the local build for checking; use a static host for deployment.
Opening the app fetches its JS; the first job also fetches the WASM asset. Images remain local, but fetching those application assets is still network activity.
Performance considerations
The example accepts at most 8 MiB, 4 million pixels and 4096 pixels per side. It runs one job at a time and terminates work after 30 seconds or cancellation. These are application limits, not guarantees about peak browser memory. Start conservatively on mobile devices.
Compression level 2 keeps the example moderate. Larger levels may take longer without meaningful savings. If the generated file is larger, the download remains the original.
Cancel and retry
Click Cancel while work is pending. This terminates the Worker and changes the status to Canceled. You can select the same file again or choose another one. Starting a job removes the previous download; a canceled or failed job has no result to download.
An empty file or one larger than 8 MiB is refused immediately. The Worker rejects invalid headers, animated PNGs, and images outside the dimension or pixel limits before loading the codec. The codec checks the remaining PNG data. After 30 seconds, a stalled job is terminated and the status reads Optimization timed out. You can select a file again to retry.
If a known-valid PNG fails, check that the .wasm asset responds successfully in the browser’s
network panel. A missing asset leaves no download, just as a codec error does. Rebuild from the
pinned packages and test the preview again. Images stay local in this example; a server receiving
an eventual upload must still validate the file.
For server-side processing across more formats, explore Transloadit's image processing service.
