Introduction
In this article, we'll explore how to implement a fully client-side image format converter that runs entirely in the browser. This powerful tool supports converting between over 13 different image formats, leveraging WebAssembly technology for high-performance processing.
Why Browser-Based Conversion?
There are several compelling reasons to implement image conversion in the browser:
1. Privacy Protection
When users convert images in the browser, their photos never leave their device. This is essential for:
- Sensitive business documents
- Personal photos requiring privacy
- Healthcare records or legal documents
2. Zero Server Costs
Traditional server-based image conversion requires:
- Powerful servers with adequate CPU resources
- Bandwidth for uploading and downloading files
- Storage for temporary files
By processing in the browser, we eliminate all infrastructure costs.
3. Instant Feedback
Processing locally means no network round-trip delay. Users get immediate results.
4. Offline Capability
Once the processing library is loaded, users can convert images without an internet connection.
Technical Architecture
Core Implementation
1. Data Structures
interface ImageFile {
id: string;
file: File;
previewUrl: string;
convertedUrl?: string;
convertedFileName?: string;
error?: string;
}
type OutputFormat =
| "jpeg" | "png" | "webp" | "gif" | "tiff" | "avif" | "heif" | "pdf"
| "jp2" | "jpxl" | "bmp" | "ppm" | "exr" | "fits";
2. Supported Formats
const POPULAR_FORMATS: { value: OutputFormat; label: string; ext: string }[] = [
{ value: "png", label: "PNG", ext: "png" },
{ value: "jpeg", label: "JPEG", ext: "jpg" },
{ value: "webp", label: "WebP", ext: "webp" },
{ value: "heif", label: "HEIF", ext: "heif" },
{ value: "avif", label: "AVIF", ext: "avif" },
];
const ALL_FORMATS: { value: OutputFormat; label: string; ext: string }[] = [
{ value: "jpeg", label: "JPEG", ext: "jpg" },
{ value: "png", label: "PNG", ext: "png" },
{ value: "webp", label: "WebP", ext: "webp" },
{ value: "gif", label: "GIF", ext: "gif" },
{ value: "tiff", label: "TIFF", ext: "tiff" },
{ value: "avif", label: "AVIF", ext: "avif" },
{ value: "heif", label: "HEIF", ext: "heif" },
{ value: "pdf", label: "PDF", ext: "pdf" },
{ value: "jp2", label: "JPEG 2000", ext: "jp2" },
{ value: "jpxl", label: "JPEG XL", ext: "jxl" },
{ value: "bmp", label: "BMP", ext: "bmp" },
{ value: "ppm", label: "PPM", ext: "ppm" },
{ value: "exr", label: "OpenEXR", ext: "exr" },
{ value: "fits", label: "FITS", ext: "fits" },
];
3. Loading libvips WebAssembly
The application uses wasm-vips, a WebAssembly port of the powerful libvips image processing library:
const VIPS_CDN = "https://cdn.jsdelivr.net/npm/wasm-vips@0.0.16/lib";
const WASM_FILES = [
{ name: "vips.js", size: 200 * 1024 },
{ name: "vips.wasm", size: 3 * 1024 * 1024 },
{ name: "vips-heif.wasm", size: 500 * 1024 },
{ name: "vips-jxl.wasm", size: 500 * 1024 },
];
async function downloadVipsFiles(onProgress?: (progress: number) => void) {
const fetchWithProgress = async (url: string, fileSize: number, index: number) => {
const response = await fetch(url);
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
if (onProgress) {
const fileProgress = (receivedLength / fileSize) * 100;
const totalProgress = ((index * 100 + fileProgress) / WASM_FILES.length);
onProgress(Math.min(Math.round(totalProgress), 95));
}
}
const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return new Blob([result]);
};
const [vipsJsBlob, vipsWasmBlob, vipsHeifBlob, vipsJxlBlob] = await Promise.all([
fetchWithProgress(`${baseUrl}/vips.js`, WASM_FILES[0].size, 0),
fetchWithProgress(`${baseUrl}/vips.wasm`, WASM_FILES[1].size, 1),
fetchWithProgress(`${baseUrl}/vips-heif.wasm`, WASM_FILES[2].size, 2),
fetchWithProgress(`${baseUrl}/vips-jxl.wasm`, WASM_FILES[3].size, 3),
]);
// Create Blob URLs for WASM files
const vipsWasmUrl = URL.createObjectURL(vipsWasmBlob);
const vipsHeifUrl = URL.createObjectURL(vipsHeifBlob);
const vipsJxlUrl = URL.createObjectURL(vipsJxlBlob);
// Patch vips.js to use local Blob URLs
const locateFileWrap = `
var __origLocateFile = n.locateFile;
n.locateFile = function(a) {
if (a === "vips.wasm") return __vipsWasmUrl;
if (a === "vips-heif.wasm") return __vipsHeifUrl;
if (a === "vips-jxl.wasm") return __vipsJxlUrl;
return __vipsWasmUrl;
};
`;
const modifiedJs = vipsJsText
.replace('var fa="";', locateFileSetup + 'var fa="";')
.replace('var fa="";', 'var fa="";' + locateFileWrap);
return URL.createObjectURL(new Blob([modifiedJs], { type: "application/javascript" }));
}
4. Converting a Single Image
const convertSingleImage = async (imageFile: ImageFile, vips): Promise<{ buffer: Uint8Array; fileName: string }> => {
const { file } = imageFile;
const uint8Array = new Uint8Array(await file.arrayBuffer());
// Load image with thumbnail (auto-rotates and limits size)
let image;
try {
image = vips.Image.thumbnailBuffer(uint8Array, 4000, {
height: 4000,
size: true,
no_rotate: true,
});
} catch {
// Fallback for formats that don't support thumbnails
image = vips.Image.newFromBuffer(uint8Array);
}
// Convert to target format
let outputBuffer: Uint8Array;
const formatInfo = ALL_FORMATS.find(f => f.value === outputFormat);
const ext = formatInfo?.ext || outputFormat;
const baseName = file.name.replace(/\.[^/.]+$/, "");
switch (outputFormat) {
case "jpeg":
outputBuffer = image.jpegsaveBuffer({ Q: 90 });
break;
case "png":
outputBuffer = image.pngsaveBuffer({ compression: 6 });
break;
case "webp":
outputBuffer = image.webpsaveBuffer({ Q: 90 });
break;
case "gif":
outputBuffer = image.gifsaveBuffer();
break;
case "tiff":
outputBuffer = image.tiffsaveBuffer({ compression: "lzw" });
break;
case "avif":
outputBuffer = image.heifsaveBuffer({ compression: "av1", Q: 80 });
break;
case "heif":
outputBuffer = image.heifsaveBuffer({ compression: "hevc", Q: 80 });
break;
case "pdf":
outputBuffer = image.pdfsaveBuffer();
break;
case "jp2":
outputBuffer = image.jp2ksaveBuffer({ Q: 90 });
break;
case "jpxl":
outputBuffer = image.jxlsaveBuffer({ Q: 90 });
break;
case "bmp":
outputBuffer = image.bmpsaveBuffer();
break;
case "ppm":
outputBuffer = image.ppmsaveBuffer();
break;
case "exr":
outputBuffer = image.exrsaveBuffer();
break;
case "fits":
outputBuffer = image.fitssaveBuffer();
break;
default:
outputBuffer = image.pngsaveBuffer({ compression: 6 });
}
return {
buffer: outputBuffer,
fileName: `${baseName}.${ext}`,
};
};
5. Batch Conversion
const convertAllImages = useCallback(async () => {
if (selectedFiles.length === 0 || !vipsRef.current) return;
setIsConverting(true);
const vips = vipsRef.current;
const convertedFiles: ImageFile[] = [];
for (const imageFile of selectedFiles) {
try {
const { buffer, fileName } = await convertSingleImage(imageFile, vips);
const blob = new Blob([new Uint8Array(buffer)]);
const convertedUrl = URL.createObjectURL(blob);
convertedFiles.push({
...imageFile,
convertedUrl,
convertedFileName: fileName,
});
} catch (err) {
console.error("Conversion error:", err);
convertedFiles.push({
...imageFile,
error: `Failed to convert ${imageFile.file.name}`,
});
}
}
setSelectedFiles(convertedFiles);
setIsConverting(false);
}, [selectedFiles, convertSingleImage]);
Service Worker for WASM Caching
The WebAssembly files are cached by a Service Worker for faster subsequent loads:
const CACHE_NAME = 'convert-wasm-cache-v1';
const VIPS_CDN = 'https://cdn.jsdelivr.net/npm/wasm-vips@0.0.16/lib';
const WASM_FILES = [
`${VIPS_CDN}/vips.wasm`,
`${VIPS_CDN}/vips-heif.wasm`,
`${VIPS_CDN}/vips-jxl.wasm`,
];
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Only handle WASM file requests from wasm-vips
if (url.pathname.endsWith('.wasm') && url.href.includes('wasm-vips')) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return networkResponse;
});
})
);
}
});
Processing Flow
Key Technologies Used
| Technology | Purpose |
|---|---|
| wasm-vips | WebAssembly port of libvips |
| libvips | Powerful image processing library |
| Service Worker | Cache WASM files for offline use |
| Blob URLs | Handle converted images without server |
Supported Formats
| Format | Extension | Description |
|---|---|---|
| JPEG | .jpg | Best for photos, lossy compression |
| PNG | .png | Lossless, supports transparency |
| WebP | .webp | Modern format, excellent compression |
| GIF | .gif | Animated images, limited colors |
| TIFF | .tiff | High quality, professional use |
| AVIF | .avif | Latest format, best compression |
| HEIF | .heif | Apple's modern format |
| Document format | ||
| JPEG 2000 | .jp2 | Wavelet-based compression |
| JPEG XL | .jxl | Next-gen JPEG |
| BMP | .bmp | Uncompressed bitmap |
| PPM | .ppm | Portable pixel map |
| OpenEXR | .exr | High dynamic range |
| FITS | .fits | Scientific imaging |
Performance Characteristics
- Library Loading: ~4-8 seconds for full wasm-vips (including HEIF and JPEG XL support)
- Conversion Speed: 1-5 seconds per image depending on size and format
- Memory Usage: Typically 2-4x the image size
- WASM Caching: After first load, library works offline
Format Conversion Examples
| Original | Target | Notes |
|---|---|---|
| PNG (10MB) | JPEG | ~90% size reduction |
| JPEG (5MB) | WebP | ~60% size reduction |
| HEIF (3MB) | PNG | Best for transparency |
| TIFF (15MB) | Single page document |
Browser Support
The wasm-vips library works in modern browsers:
- Chrome 89+
- Firefox 89+
- Safari 15+
- Edge 89+
Conclusion
Browser-based image format conversion is a powerful solution that protects user privacy while providing excellent performance. Using WebAssembly-based libvips (via wasm-vips) enables professional-grade image processing entirely in the browser. The Service Worker ensures fast subsequent loads by caching the WASM modules.
This implementation supports 13+ formats including modern formats like AVIF, HEIF, and JPEG XL, making it one of the most capable browser-based image converters available.
Try it yourself at Free Image Tools
Experience the power of browser-based image conversion. No upload required - your images stay on your device!


Top comments (0)