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 the real @jsquash/oxipng package, 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
Create a project with these files. 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.1" }
}
Run npm install with Node.js 24 or newer and retain the generated lockfile. 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 ?url import is a Vite asset import, not a browser package URL. Vite emits the WASM file with
the application. The codec path is pinned to this package version; verify it when upgrading.
See jSquash's repository for the supported high-level API
and multithreading options.
Browser compatibility
Run npm run dev and open Vite's local URL. Before publishing, run npm run build, then
npm run preview and test the production build too. The app requires module Workers,
WebAssembly, File/Blob APIs and object URLs. Unsupported browsers receive the error state rather
than a claimed universal version guarantee.
The first optimization must download the application's JS and WASM assets. 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.
Use cases
This approach can reduce PNG upload size for screenshots, diagrams and transparent assets. Validate decoded pixels with representative fixtures, including transparency, and check invalid files, cancel/retry flows and missing WASM responses. Do not use it as proof that an upload is harmless: the receiving server must still validate files.
Conclusion
A real browser PNG optimizer needs a working codec, bounded work and explicit lifecycle handling. This implementation keeps PNG processing in a disposable Worker and releases download URLs. For server-side processing across more formats, explore Transloadit's image processing service.
