@gunny/compress-image
v0.1.0
Published
Compress images in the browser using OffscreenCanvas and Web Workers
Maintainers
Readme
@gunny/compress-image
Compress images in the browser before upload. Uses OffscreenCanvas and can offload the work to a Web Worker so the main thread stays responsive.
- 🖼️ Decodes, resizes and re-encodes JPEG / WebP / PNG (any browser-decodable input)
- 🎨 Actually shrinks PNGs via a built-in palette quantization + DEFLATE encoder
- ⚙️ Configurable
quality,format,maxWidth/maxHeight - 🧵 Worker-safe: call
compress()from your own Web Worker - 🪟 Built on
OffscreenCanvas+createImageBitmap - 🧩 TypeScript-first, ESM only, zero runtime dependencies
Install
npm install @gunny/compress-imageOr with your package manager of choice:
pnpm add @gunny/compress-image
yarn add @gunny/compress-imagePrefer no build step? Use it straight from a CDN — see CDN.
Quick start
import { compress } from '@gunny/compress-image';
const input = document.querySelector<HTMLInputElement>('#file')!;
const file = input.files![0];
const result = await compress(file, {
quality: 0.8,
format: 'image/webp',
maxWidth: 1920,
maxHeight: 1080,
});
// result.blob is ready to upload
const formData = new FormData();
formData.append('file', result.blob, 'photo.webp');CDN
Load the library straight from a CDN with an ESM import — no bundler required.
Because the package ships ESM only, use a <script type="module"> tag and import
the built entry:
<input type="file" id="file" accept="image/*" />
<script type="module">
import { compress } from 'https://unpkg.com/@gunny/compress-image/dist/index.js';
const input = document.querySelector('#file');
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
const result = await compress(file, { quality: 0.8, format: 'image/webp' });
console.log(result.blob);
});
</script>The bare package URL resolves to the same entry through the package's main
field, so this also works:
<script type="module">
import { compress } from 'https://unpkg.com/@gunny/compress-image';
</script>API
compress(source, options?)
function compress(
source: Blob | File | ImageBitmap,
options?: CompressOptions,
): Promise<CompressResult>The core compression routine. Accepts a Blob/File or ImageBitmap, runs on
the current thread (main thread or inside your own worker), and returns a
Promise<CompressResult>.
Options
| Option | Type | Default | Description |
| -------------- | --------- | -------------- | ------------------------------------------------------------------------ |
| quality | number | 0.8 | Output quality 0–1. For PNG it maps to the palette size (lossy quantization). |
| format | string | 'image/jpeg' | Output MIME type: image/jpeg, image/webp, image/png, … |
| maxWidth | number | 4096 | Maximum output width in px. |
| maxHeight | number | 4096 | Maximum output height in px. |
| background | string | '#ffffff' | Fill color for formats without alpha (e.g. JPEG). |
| allowUpscale | boolean | false | Allow small images to be enlarged to the max dimensions. |
| allowLarger | boolean | false | Return the re-encoded blob even when it is not smaller than the original. |
Note about PNG: browsers only re-encode PNG losslessly through a canvas, so
convertToBlobcannot shrink a PNG and often makes it larger. Forimage/pngthis library therefore reads the raw pixels and re-encodes them with a built-in, dependency-free encoder (UPNG-style): opaque images (and images whose alpha is only fully opaque/transparent) are quantized to an indexed-color palette, images with partially transparent pixels are quantized in RGBA space and written as true-color RGBA, and the result is DEFLATE-compressed.qualitymaps to the number of colors, exceptquality: 1writes the image losslessly (true-color, no quantization). As with every format, the original image is returned when the result is not smaller than the input — this is whatresult.unchangedreports. SetallowLarger: trueto force the re-encoded output.
CompressResult
| Field | Type | Description |
| ---------------------------------- | --------- | ---------------------------------------------------------------------- |
| blob | Blob | The compressed image. |
| format | string | Output MIME type. |
| quality | number | Quality that was applied. |
| width / height | number | Output dimensions in px. |
| originalWidth / originalHeight | number | Original dimensions in px. |
| originalSize | number | Original size in bytes. |
| size | number | Compressed size in bytes. |
| ratio | number | size / originalSize (lower is better). |
| unchanged | boolean | true when the original was returned because compression didn't shrink it. |
Running in a Worker
compress() only uses APIs that are available inside Workers
(createImageBitmap, OffscreenCanvas, Blob), so you can call it from your
own worker file. Write a small worker that imports the library and wires up a
message handler:
// worker.ts
import { compress } from '@gunny/compress-image';
import type { CompressOptions } from '@gunny/compress-image';
interface WorkerRequest {
source: Blob | ImageBitmap;
options?: CompressOptions;
}
self.addEventListener('message', async (event: MessageEvent<WorkerRequest>) => {
try {
const result = await compress(event.data.source, event.data.options);
self.postMessage(result);
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
});Then load that worker from the main thread. The syntax depends on the bundler:
Vite
// vite-env.d.ts — make sure Vite's client types are loaded
/// <reference types="vite/client" />
// main.ts
import CompressWorker from './worker?worker';
const worker = new CompressWorker();
worker.onmessage = (event) => {
const result = event.data; // CompressResult | { error: string }
if ('error' in result) throw new Error(result.error);
console.log(result.blob);
};
worker.postMessage({ source: file, options: { quality: 0.8, format: 'image/webp' } });For strict CSP environments, inline the worker instead:
import CompressWorker from './worker?worker&inline';
const worker = new CompressWorker();Webpack 5
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.onmessage = (event) => {
const result = event.data; // CompressResult | { error: string }
if ('error' in result) throw new Error(result.error);
console.log(result.blob);
};
worker.postMessage({ source: file, options: { quality: 0.8, format: 'image/webp' } });CDN (no bundler)
No bundler? Use a module worker that imports compress straight from the CDN.
Put the worker logic in its own worker.js file — no template-literal strings
needed:
// worker.js
import { compress } from 'https://unpkg.com/@gunny/compress-image';
self.addEventListener('message', async (event) => {
try {
const result = await compress(event.data.source, event.data.options);
self.postMessage(result);
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
});Then load it from the page:
<input type="file" id="file" accept="image/*" />
<script type="module">
const worker = new Worker('./worker.js', { type: 'module' });
worker.onmessage = (event) => {
const result = event.data;
if ('error' in result) throw new Error(result.error);
console.log(result.blob);
};
const input = document.querySelector('#file');
input.addEventListener('change', () => {
const file = input.files?.[0];
if (file) worker.postMessage({ source: file, options: { quality: 0.8, format: 'image/webp' } });
});
</script>Module workers must be served over HTTP(S) —
file://is not supported.
Browser support
| Feature | Chrome/Edge | Firefox | Safari |
| -------------------------- | ----------- | ------- | ------ |
| OffscreenCanvas | 69+ | 105+ | 16.4+ |
| createImageBitmap | 50+ | 42+ | 15+ |
| Worker + OffscreenCanvas | 69+ | 105+ | 16.4+ |
The library encodes with OffscreenCanvas, so it requires a browser that
supports OffscreenCanvas and createImageBitmap (see the table above).
Demo
A small browser demo is included in demo/. Build the library first, then serve:
npm install
npm run build
npm run demo # http://localhost:8080/Development
npm install # install dev dependencies
npm run build # bundle to dist/ (esm + d.ts)
npm test # run the vitest suite
npm run typecheck # type-check the project