Library / SDK
kleisauke/wasm-vips avatar
kleisauke/wasm-vips

wasm-vips: libvips in the browser, with the cross-origin isolation tax

libvips for the browser and Node.js, compiled to WebAssembly with Emscripten.

891 stars44 forksC++MIT

At a glance

What is it?
wasm-vips is an MIT licensed WebAssembly build of libvips for the browser and Node.js, compiled with Emscripten, that keeps whole images out of memory by streaming pipelines a section at a time. It suits a front-end team that needs real image processing without a server, and it is still under early development, with the version number sitting at 0.0.x.
Who is it for?
wasm-vips fits a browser application that must resize, crop or re-encode images on the client and would rather not ship a native module or call a server, because the demand-driven pipeline model carries over intact and the streaming behaviour is what makes it viable in a browser tab.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly C++, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Pipelines, not pixel loops

The README describes the model in a way that is easy to miss, and it is the whole reason this library is interesting.

Programs that use wasm-vips do not manipulate images directly. Instead they create pipelines of image processing operations building on a source image. When the end of the pipe is connected to a destination, the whole pipeline executes at once, streaming the image in parallel from source to destination a section at a time.

That is libvips' own design, and it is the design that makes libvips fast and memory-light on a server, where a big image fits in RAM. In a browser tab, where memory is a much tighter constraint, the same design is the difference between workable and not. Because wasm-vips is parallel, it is quick, and because it does not need to keep entire images in memory, it is light.

The example in the README shows the shape of a program, and it uses the `using` keyword to bind each stage of the pipeline:

ts
using im = vips.Image.newFromFile('owl.jpg');

using embed = im.embed(100, 100, 3000, 3000, {
  extend: 'mirror'
});

using multiply = embed.multiply([1, 2, 1]);

using mask = vips.Image.newFromArray([
  [-1, -1, -1],
  [-1, 16, -1],
  [-1, -1, -1]
], 8.0);

using convolve = multiply.conv(mask, {
  precision: 'integer'
});

const outBuffer = convolve.writeToBuffer('.jpg');

Each stage is a separate object, and the final writeToBuffer call is what triggers execution. Nothing is computed until the pipe is connected to a destination, which is what allows libvips to fuse the operations and stream rather than building an intermediate image for each step.

The `using` declarations are not decoration either. The README notes that if you are not transpiling, this requires support for the `using` keyword, and on Node.js you can enable it with the --js-explicit-resource-management CLI flag. The explicit resource management is what releases the underlying wasm memory deterministically rather than leaving it to the garbage collector, which matters when the whole point is bounded memory use.

The primary language is C++, the same as libvips itself, with the JavaScript layer generated on top.

Cross-origin isolation, and the headers you have to serve

The browser story has a precondition that catches a lot of people out, and the README is explicit about it.

Since wasm-vips requires the SharedArrayBuffer API, the website needs to opt in to a cross-origin isolated state, by serving the following HTTP headers on both the main document and the vips*.js script:

http
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

This is the cost of parallelism in the browser. SharedArrayBuffer is a cross-origin-isolated-only API, because without isolation a page could observe another page's memory, and the whole shared-memory-plus-threads model would be an information leak. So the two headers are not an optimisation, they are the price of admission.

The consequence for deployment is real. A site that loads third-party scripts, ads, or analytics from another origin cannot be cross-origin isolated, because those resources would have to opt in via CORP headers too. The README links to the web.dev explainer on COOP and COEP for the details.

The browser usage itself is short. The vips.js or vips-es6.js file and the vips.wasm have to be served from the same directory, and the module is initialised like this:

html
<script src="vips.js"></script>
<script type="module">
  const vips = await Vips();
</script>

The ES6 module variant imports Vips from './vips-es6.js' and awaits Vips() the same way, with the caveat that it requires support for ES6 modules in workers.

So the practical checklist before you can run this in a browser is: serve the wasm and the script from the same origin as the document, set the two headers on both, and accept that any cross-origin subresource on the page may cost you the isolation. For a page that is entirely your own code, that is a reasonable trade. For a page with a third-party tag manager, it is a blocker, and the answer then is to do the image work on the server, or in a worker on a separate origin that is isolated.

Node.js, Deno, and a dual-package export map

The Node.js path is more conventional, and it is the one most server-side users will take.

On Node.js, wasm-vips is published as a dual-package, so it can be imported as both CommonJS and ES6 module:

js
// ES6 module
import Vips from 'wasm-vips';

// CommonJS module
const Vips = require('wasm-vips');

The package.json confirms the shape of that. The package is marked type commonjs, the main field is lib/vips-node.js, and the exports map has separate browser and node branches: for the browser it resolves import to ./lib/vips-es6.js and require to ./lib/vips.js, and for node it resolves import to ./lib/vips-node.mjs and require to ./lib/vips-node.js, with ./lib/vips.js as the default. That is the standard dual-package pattern, and it means a bundler picks a build appropriate to the target rather than shipping the Node build to a browser.

The exports map also lists the wasm binaries individually, which is worth noting because it shows what the library can actually do. There is ./vips.wasm, the core build, and then three optional add-ons: ./vips-heif.wasm, ./vips-jxl.wasm and ./vips-resvg.wasm. Those correspond to HEIF image support, JPEG XL support and resvg, which is an SVG rasteriser. They are separate files rather than one bundled module, so you pay only for the formats you actually use, and the file list in the package includes all four.

Initialisation in Node is either with top-level await or with a promise:

js
// Usage with top-level await
const vips = await Vips();

// Usage with .then
Vips().then((vips) => {
  // Code here
});

Deno is the third target, and it is a one-liner thanks to npm compatibility:

js
import Vips from 'npm:wasm-vips';

const vips = await Vips();

The browser condition in the exports map means the Deno import of the npm package resolves to the browser build, which is the right one for a runtime that has no native libvips.

SIMD and exception handling decide whether it runs at all

The engine requirements are the other hard constraint, and they are stated with unusual precision.

An engine that supports WebAssembly SIMD and WebAssembly Exception Handling is required. The README says this is present on most major browser engines and is part of Baseline 2023.

The per-engine minimum versions follow. For V8-based engines, at least version 9.5, which corresponds to Chrome 95, Node.js 17.0.0 and Deno 1.16.0. For Spidermonkey-based engines, the JavaScript engine used in Mozilla Firefox and whose version numbers are aligned, at least version 100 is required. For JavaScriptCore-based engines, the built-in JavaScript engine for WebKit, at least version 615.1.17 is required, which corresponds to Safari 16.4.

The table in the README confirms the same floors for the specific browsers: Chrome 95+, Firefox 100+, Safari 16.4+, Edge 95+, Node.js 17.0+ and Deno 1.16+.

So the practical minimum is Safari 16.4, which is a 2023 release. Any environment older than that will not load the module, and there is no fallback path in the README for a runtime without SIMD. That is a reasonable position for a library that depends on WebAssembly threads for its parallelism, and it is the sort of constraint that is much easier to discover now than in a customer's browser.

One detail worth knowing is the correspondence between engine families and version numbering, which the README spells out because it is genuinely confusing. V8 versions, Firefox's Spidermonkey versions and WebKit's JavaScriptCore versions are unrelated counters, so the mapping from a browser version to a minimum is what actually matters, and the README gives both.

The exception handling requirement is the less discussed of the two. It is what allows the C++ code compiled by Emscripten to propagate errors as JavaScript exceptions rather than aborting, and it is why the library is unusable in an engine with SIMD but no exception handling support.

How the build works, and what a fork would cost you

The build is a Docker image around the Emscripten SDK, and the Dockerfile shows the whole toolchain.

The base image is docker.io/emscripten/emsdk:6.0.12, and the apt layer installs build-essential, ccache, libglib2.0-dev, pkgconf, ninja-build for Meson and pipx, with Meson installed through pipx. The build system in the repository is Meson, with meson.build and meson_options.txt at the top level.

Two patches make this more than a stock Emscripten build. First, Emscripten patches: the Dockerfile curls a compare URL from the emscripten-core repository against the author's fork, applies it to the Emscripten source in the SDK, clears the emcc cache and rebuilds the sysroot with embuilder, forcing it. Second, Rust: rustup is installed with a minimal profile, the wasm32-unknown-emscripten target and the rust-src component, and the toolchain is pinned to a nightly dated 2026-10-14.

Caching is handled explicitly, with an Emscripten cache directory, a Cargo cache and a ccache directory, and _EMCC_CCACHE=1 set to enable it. The entrypoint is /bin/bash and the command is ./build.sh, so the build script is the entry point rather than a series of Dockerfile steps.

The npm scripts mirror that. The build script is a docker build followed by a docker run that mounts the working directory and executes build.sh inside, which means the whole build happens in the container, and the test scripts split into lint via Biome and unit tests run with npm --prefix test/unit test, with a separate web test served through the serve package and a bench script that has node and web variants.

The repository layout supports this: a lib/ directory for the generated JavaScript, a src/ directory, a build/ directory, a test/ directory, a playground/, a Dockerfile, a build.sh, a meson.build, a .clang-format and a .clang-tidy for C++ style, and a THIRD-PARTY-NOTICES.md file for the licences of everything vendored in.

For an adopter, none of that matters unless you intend to build it yourself. The published npm package is what you install, and the build machinery only matters if you are working on the library or need a custom build of libvips with different loaders.

Early development, version 0.0.19, and a licence split

The maturity statement is in the README and it is unambiguous. There is a note that this library is still under early development, pointing at issue 1.

The version numbers agree with that. The recent releases are v0.0.19 from 2026-09-29, v0.0.18 from 2026-06-09 and v0.0.17 from 2026-03-20, and the version in package.json is 0.0.19. A 0.0.x version line carries no compatibility promise in the usual sense, and this is one where the API can change between releases.

The cadence itself is healthy, though. The last push was on 2026-10-14, on the master branch, and the repository is not archived. Three releases in about six months for a project in early development is a reasonable rate, and the pinned nightly Rust toolchain in the Dockerfile, dated 2026-10-14, shows the build is being kept current with the toolchain rather than left to rot.

The licence is MIT for the wrapper, in the LICENSE file at the root of the tree, and there is a separate THIRD-PARTY-NOTICES.md for the vendored code. That second file matters more than usual here, because the project embeds libvips itself, and libvips is licensed under LGPL 2.1+, as the companion ruby-vips README in the same ecosystem states. A WebAssembly build of an LGPL library raises a question about how the LGPL terms are satisfied in a binary that is distributed as a wasm file, and the THIRD-PARTY-NOTICES.md file is where you would look for the project's answer. This review does not interpret those terms, and it is worth reading the notices rather than assuming the MIT grant on the wrapper covers everything inside the wasm binary.

The documentation is hosted at kleisauke.github.io/wasm-vips, and the repository has a CHANGELOG.md at the root for the version history.

Editorial conclusion

wasm-vips fits a browser application that must resize, crop or re-encode images on the client and would rather not ship a native module or call a server, because the demand-driven pipeline model carries over intact and the streaming behaviour is what makes it viable in a browser tab. It is the wrong choice if you cannot serve cross-origin isolation headers, since SharedArrayBuffer is required and that rules out a plain static host with third-party assets, and it is the wrong choice for a production service that needs a versioned, stable API, because the package is at 0.0.19 and the README says it is still under early development. Before you adopt it, measure the WebAssembly payload you would be shipping, and confirm your hosting can set the Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy headers on the document and the vips script.

Frequently asked questions

How do I install wasm-vips?

The package is on npm and installs with `npm install wasm-vips`, or with `yarn add wasm-vips`. On Node.js it is published as a dual-package, so it can be imported as either CommonJS or an ES6 module.

Why does wasm-vips need cross-origin isolation headers?

Because it requires the SharedArrayBuffer API, which is only available in a cross-origin isolated context. The README says to serve Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin on both the main document and the vips*.js script.

Which browsers and runtimes can run wasm-vips?

An engine needs WebAssembly SIMD and WebAssembly Exception Handling. The README gives minimums of Chrome 95+, Firefox 100+, Safari 16.4+, Edge 95+, Node.js 17.0+ and Deno 1.16+, which it says is part of Baseline 2023.

Does wasm-vips load whole images into memory?

No. The README says programs create pipelines of operations and that when the pipe is connected to a destination the whole pipeline executes at once, streaming the image in parallel from source to destination a section at a time, so it does not need to keep entire images in memory.

Is wasm-vips ready for production use?

The README carries a note that the library is still under early development, and the recent releases are v0.0.19, v0.0.18 and v0.0.17. The last push was on 2026-10-14 and the repository is not archived.

Official sources

  1. kleisauke/wasm-vips on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/kleisauke-wasm-vips.svg)](https://hysenlabs.com/projects/kleisauke-wasm-vips)

Was this useful?