Table of Contents
When working with images, be it for a website, a game, or any other application, you might need to convert them from one format to another. For this there are a multitude of libraries and tools available, most of which are web-based and require you to upload your image to a server. This can be a hassle, especially if you are working with sensitive data or if you are on a slow connection.
This is where WebAssembly comes in.
Introduction
The WASM Image Converter converts images entirely in the browser. You drop a file in, pick a target format, and get the result back without a single byte leaving your machine. No upload, no server, no privacy questions.
The app consists of three parts:
- A Rust library compiled to WebAssembly, doing the actual conversion with image-rs and resvg
- A Web Worker that runs the wasm module off the main thread
- A Nuxt frontend with drag-and-drop input, format selection, and a progress bar
This post walks through the interesting parts of each layer: the conversion pipeline in Rust, the quirks of individual image formats, the JS interop boundary, and why the Web Worker is not optional.
The Conversion Pipeline in Rust
The Rust core boils down to three steps: load the bytes into a DynamicImage, pre-process it for the target format, and encode it. Each step is its own function, which keeps the format-specific logic contained.
fn load_image(
file: &[u8],
source_type: Option<SourceType>,
config: Option<Settings>,
) -> Result<image::DynamicImage, ConvertError> {
let load = match source_type {
Some(SourceType::Raster(file_type)) => {
image::load_from_memory_with_format(file, file_type)?
}
Some(SourceType::Svg) => {
let svg_settings = match config {
Some(Settings::Svg(settings)) => settings,
_ => SvgSettings::default(),
};
let img = svg_to_png(file, svg_settings)?;
image::load_from_memory_with_format(&img, ImageFormat::Png)?
}
None => image::load_from_memory(file)
.map_err(|e| ConvertError::UnknownFileType(e.to_string()))?,
};
Ok(load)
}
The source format comes from the file's MIME type, but the None arm matters: if the type is unknown, image::load_from_memory falls back to content sniffing. Browsers report surprisingly unreliable MIME types for less common formats, so trusting the file contents over the label avoids a class of support requests.
Encoding is the short part. image-rs writes any supported format to an in-memory buffer through a Cursor:
fn write_image(
img: &image::DynamicImage,
file_type: Option<ImageFormat>,
) -> Result<Vec<u8>, ConvertError> {
let mut output: Vec<u8> = Vec::new();
let target_type = file_type.unwrap_or(ImageFormat::Png);
img.write_to(&mut Cursor::new(&mut output), target_type)?;
Ok(output)
}
Handling Format Quirks
The step in between is where most of the real-world fixes ended up. Image formats are standards, which means every one of them solved the same problem slightly differently. They disagree about color models, alpha channels, and dimensions, and image-rs surfaces those disagreements as encoding errors. Instead of forwarding them to the user, the converter normalizes the image first:
match target_type {
ImageFormat::Jpeg
| ImageFormat::Qoi
| ImageFormat::Farbfeld
| ImageFormat::Pnm
| ImageFormat::Tga => image::DynamicImage::ImageRgb8(img.to_rgb8()),
ImageFormat::Ico => img.resize(256, 256, image::imageops::FilterType::Lanczos3),
ImageFormat::OpenExr => image::DynamicImage::ImageRgba32F(img.to_rgba32f()),
_ => img,
}
Three examples of what this catches:
- JPEG has no alpha channel. Converting a transparent PNG to JPEG fails unless you drop the alpha first, so the image is flattened to RGB8.
- ICO caps out at 256×256. Anything larger gets resized with a Lanczos filter before encoding.
- OpenEXR stores floats. The pixel data has to be converted to 32-bit float RGBA before the encoder accepts it.
None of this is hard individually, but discovering each case meant feeding the converter a weird file and reading the resulting error. Say what you will about integration tests, a public demo page finds edge cases faster.
SVGs Are Not Bitmaps
image-rs only handles raster formats, so SVG input takes a detour: resvg rasterizes the SVG to PNG at a user-configurable size, and that PNG then enters the normal pipeline. Text rendering and embedded raster images are enabled as resvg features in the crate config, since both show up constantly in real-world SVGs.
Crossing the JS Boundary
The public API is a single function exported through wasm-bindgen:
#[wasm_bindgen(js_name = convertImage)]
pub fn convert_image(
file: &Uint8Array,
src_type: &str,
target_type: &str,
cb: &js_sys::Function,
convert_settings: &JsValue,
) -> Result<Uint8Array, JsValue> {
// ...
}
A few details worth pointing out:
- Bytes cross the boundary as
Uint8Array, in both directions. The file is copied into wasm memory withfile.to_vec(), converted, and the result is copied back out. For typical image sizes the copies are negligible next to the encode time. - Progress reporting uses a plain
js_sys::Functioncallback. The Rust side calls it with a percentage and a status message at each pipeline stage, which feeds the progress bar in the UI. - Settings arrive as an arbitrary
JsValueand are deserialized into a typedSettingsenum via serde andgloo-utils. Invalid settings become a typedConvertErrorinstead of a panic.
Errors follow the same rule: every ConvertError is stringified into a JsValue at the boundary, so the frontend always receives a catchable exception rather than an aborted wasm instance.
Keeping the UI Responsive with a Web Worker
WebAssembly is fast, but it still runs synchronously on whatever thread calls it. Encoding a large PNG can take a few seconds, and doing that on the main thread freezes the page, including the progress bar that is supposed to show the conversion is running.
The fix is to run the wasm module inside a Web Worker and talk to it with messages:
globalThis.addEventListener('message', (e: MessageEvent<WorkerRequest>) => {
init().then(() => {
const { inputFile, inputType, outputType, settings } = e.data
const res = convertImage(inputFile, inputType, outputType, callback, settings)
globalThis.postMessage({
type: WorkerMessageType.DONE,
payload: { success: true, data: res },
} as WorkerMessage)
}).catch((e) => {
globalThis.postMessage({
type: WorkerMessageType.ERROR,
payload: { success: false, error: String(e) },
})
})
})
function callback(progress: number, message: string) {
globalThis.postMessage({
type: WorkerMessageType.PROGRESS,
payload: { progress, message },
} as WorkerMessage)
}
The worker initializes the wasm module, runs the conversion, and forwards the progress callback as PROGRESS messages. The main thread only ever sends one request and receives typed PROGRESS, DONE, or ERROR messages back, so the UI stays interactive no matter how large the input is.
Keeping the Binary Small
Shipping a wasm module means every user downloads your compiled Rust. Two settings in the release profile make a real difference:
[profile.release]
lto = true
opt-level = "s"
Link-time optimization removes unused code across crate boundaries, and opt-level = "s" trades some raw speed for size. Together with wasm-pack's default wasm-opt pass, the module stays small enough to load comfortably, even though it bundles decoders and encoders for a dozen formats plus an entire SVG renderer.
Where the Project Went
The converter still runs at tomvoet.github.io/wasm-image-convert, the code lives in the wasm-image-convert repository, and it keeps an entry on my projects page. Active development, however, has moved: the project grew into re;file labs, where the same client-side approach now covers image editing, document handling, and more file tooling beyond conversion. The image tools live in the refilelabs/image repository, still open source.
The core idea survived the move unchanged: the browser is a perfectly good runtime for file processing, and with Rust and WebAssembly you don't have to choose between convenience and keeping your files to yourself.