The Client Is the New Server

For a long time, image processing lived on the server because early browsers couldn’t do anything more sophisticated than displaying images, so any resizing, re-encoding, or format conversion had to happen somewhere with real computational power.

A user would upload a raw 8MB photograph from their device, your server would spin up ImageMagick, grind through the conversion, and write the result to disk. Then your CDN would serve that result to everyone else.

That workflow remains valid for many use cases as ImageMagick and Sharp are actively maintained, widely deployed, and genuinely excellent for batch processing your own assets.

But for the specific case of processing images that arrive from user uploads, the economics have fundamentally shifted: the browser is now genuinely capable of professional-grade image compression, without a round trip to your infrastructure, without a server CPU bill, and without the user’s private EXIF data ever crossing the network.

The technology that made this possible is WebAssembly. The library that makes it practical is @jsquash.

This lesson is about understanding what both of those things actually are, not just how to call their APIs, but why they work the way they do, and what mental model to carry into the implementation lessons that follow.


What Problem does Client-Side Compression Solve?

Let’s be specific about what’s wrong with the server-side approach for user uploads, because it’s “old” is not a compelling reason to abandon something. The real problems are cost, bandwidth, and privacy and they compound each other in ways that are easy to overlook when you’re looking at one image at a time.

Cost

When a user uploads a raw image, three things happen that you pay for. First, their device transmits the full, unoptimised file over the network. A modern iPhone photograph in HEIC is typically 3 to 6MB. A JPEG from a standard DSLR body runs 6 to 10MB. That bandwidth comes from the user’s data plan on the upload side, and from your egress bill if you’re running your own infrastructure.

Second, your server CPU processes the conversion, and encoding AVIF in particular is genuinely CPU-intensive. That cost happens on hardware you’re paying for by the hour. Third, the result lands in object storage and gets served via CDN, but you’ve already absorbed costs one and two for every single upload.

Privacy

The privacy concern is less obvious but matters enormously for consumer applications. Every photograph taken on a modern smartphone embeds EXIF metadata: the precise GPS coordinates of where the photo was taken, the device model, the timestamp, sometimes even the camera’s serial number.

When a user uploads that image to your server, all of that raw data arrives with it. Most developers don’t intend to collect location data from their users’ photographs, but the traditional upload flow does exactly that unless someone explicitly strips it while most codebases don’t.

Solution

Client-side processing eliminates all three problems at the source. The file never leaves the device in its original form. The CPU doing the encoding is the user’s, which costs them nothing measurable and costs you absolutely nothing. The EXIF data can be selectively extracted before compression happens, meaning the user’s location reaches your database only if you deliberately put it there and not as a side effect of how uploads work.

That’s the architectural shift @jsquash enables. Now let’s understand what it actually is.


What @jsquash Is (and What It Is Not)

@jsquash is not a new image codec. The codecs it uses are battle-tested libraries (written in C, C++, and Rust) developed over years by engineers at Mozilla, Google, the Alliance for Open Media, and others. These are the same codecs that professional desktop tools use. Squoosh, Google’s browser-based compression tool that most developers have encountered at some point, runs on them.

@jsquash is what happens when you compile those codecs to WebAssembly and wrap each one in a clean JavaScript module. The result is a collection of independent npm packages, each responsible for exactly one codec or tool. The full list from the official repository is worth knowing precisely, because the naming is not always obvious:

  • @jsquash/jpeg wraps MozJPEG, which produces better JPEG quality at a given file size compared to the standard libjpeg encoder
  • @jsquash/webp wraps libwebp, Google’s reference implementation for the WebP format
  • @jsquash/avif wraps libavif, the Alliance for Open Media’s reference AVIF library - libavif itself uses libaom internally as its AV1 encoder, but what @jsquash exposes is the libavif API, not libaom directly
  • @jsquash/jxl wraps libjxl for JPEG XL encode and decode - relevant if you want to experiment with the format, though browser support remains limited in 2026
  • @jsquash/png wraps the Rust PNG crate for full PNG encode and decode, including 16-bit colour support
  • @jsquash/oxipng is a separate package using OxiPNG - a Rust-based PNG optimiser - for reducing the size of an existing PNG without re-encoding from raw pixel data
  • @jsquash/resize provides image resizing using multiple algorithms: Lanczos, hqx, and magic-kernel, supporting both downscaling and upscaling

Every package follows the same contract: it exports a decode function and an encode function. You load the codec you need, convert your file into an intermediate pixel representation, and encode it to the target format. The API surface is deliberately minimal, which makes the mental model carry cleanly across the entire series.

SvelteKit users: @jsquash/png needs a Vite workaround

Vite’s dependency optimizer has a known conflict with @jsquash/png. In a SvelteKit project, you may see silent build failures or module resolution errors when using it. The fix is to exclude it from Vite’s optimizer in vite.config.ts:

export default defineConfig({
	optimizeDeps: {
		exclude: ['@jsquash/png']
	}
})

The other @jsquash packages do not require this workaround.


WebAssembly: The Engine Behind the API

You don’t need to write WASM by hand, but understanding the basic mechanics explains several patterns you’ll use throughout this series and it makes the lazy-loading requirement in the next section feel obvious rather than arbitrary.

WebAssembly is a binary instruction format that modern browsers execute directly. It sits alongside JavaScript in the browser’s runtime, but it is not JavaScript. It is the compiled output of a systems language (C, C++, or Rust) targeted at a virtual machine that browsers know how to run at near-native speed.

Think of it this way: the C++ source code for libavif (the AVIF reference library) is not running in your browser. What’s running is a .wasm binary produced by feeding that C++ through a compiler called Emscripten, which outputs WebAssembly bytecode instead of x86 or ARM machine code. The same algorithm, different packaging. A packaging that the browser’s JS engine can execute in a sandboxed environment.

How fast is WASM in practice? Benchmarks show that WASM is typically 1.5 to 2.5x slower than equivalent native code for compute-heavy workloads, and for complex image encoders like AVIF the gap can reach 3x or more.

That’s a real penalty, but the comparison that matters is between WASM running on the user’s hardware at zero cost to you, versus native code running on a server you pay for by the hour. As devices get faster, the trade-off only improves.

The sandbox is a feature, not a limitation

WASM modules run in the same sandboxed environment as JavaScript. They can’t access the file system directly, make network requests, or touch the DOM. They receive data through JavaScript, compute on it, and return results. This is precisely what makes it safe to load codec binaries from npm. Your JavaScript code always owns the data; the WASM module only borrows it.


The Decode → ImageData → Encode Pipeline

Every @jsquash operation follows the same two-step structure:

  1. decode the source file into a raw pixel representation
  2. encode that representation into the target format. The intermediate format connecting those two steps is ImageData.

ImageData is a standard Web API interface with three properties: width, height, and data. The data property is a Uint8ClampedArray, a typed array of 8-bit unsigned integers where every four consecutive values represent one pixel in RGBA order: red, green, blue, alpha, each from 0 to 255. A 100×100 pixel image produces a data array of exactly 40,000 bytes. There is no compression in ImageData; it is the raw pixel grid in its most unambiguous, format-neutral form.

This is the bridge that makes codec interoperability possible. ImageData is the agreed-upon handshake between every codec in the @jsquash family. Decode a JPEG and you get ImageData. Pass that same ImageData to the AVIF encoder and you get an AVIF ArrayBuffer. The codecs don’t need to know anything about each other, they just both speak ImageData.

Loading diagram...

Here’s the complete decode → encode flow in plain JavaScript, with no Svelte yet. The goal is to make the shape of the API completely clear before adding the reactive layer in Lesson 3:

// src/lib/image/convert.js
// A single JPEG-to-WebP conversion - showing the API shape explicitly.
//
// NOTE: These are top-level static imports, used here to keep the example
// readable and focused on the API shape. In production, @jsquash codecs must
// be imported dynamically inside the async function that needs them - the
// next section explains exactly why and shows the correct pattern.

import { decode as decodeJpeg } from '@jsquash/jpeg'
import { encode as encodeWebp } from '@jsquash/webp'

export async function jpegToWebp(file) {
	// Step 1: Read the File object as a raw ArrayBuffer.
	// File.arrayBuffer() is a standard Web API - no Node.js required.
	const sourceBuffer = await file.arrayBuffer()

	// Step 2: Decode the JPEG bytes into a pixel grid.
	// decodeJpeg returns: { data: Uint8ClampedArray, width: number, height: number }
	// No information about the original format is retained - you have pure pixels.
	const imageData = await decodeJpeg(sourceBuffer)

	// Step 3: Encode the pixel grid as WebP.
	// encodeWebp returns an ArrayBuffer containing the compressed WebP bytes.
	// quality: 80 is a solid starting point - perceptually transparent for most
	// photographic content, meaning the difference from the original is
	// imperceptible to most viewers under normal viewing conditions.
	const webpBuffer = await encodeWebp(imageData, { quality: 80 })

	// Step 4: Wrap the ArrayBuffer in a Blob so it can be previewed or uploaded.
	return new Blob([webpBuffer], { type: 'image/webp' })
}

The function reads like its intent: give me a JPEG file, I’ll give you a WebP blob. The intermediate imageData variable is where the codec boundary sits. Swap decodeJpeg for decodeAvif if the source is an AVIF file. Swap encodeWebp for encodeAvif if you want an AVIF output. The pipeline structure stays identical because both codecs speak the same ImageData language.

Understanding File.arrayBuffer() is worth a moment. A File object in the browser is a reference to data in memory, it doesn’t expose its bytes directly. Calling .arrayBuffer() materialises those bytes as an ArrayBuffer: a fixed-length binary buffer in JavaScript’s memory.

Once you have an ArrayBuffer, you own those bytes and can pass them to any API that works with binary data, including WASM modules. This is the point where a browser abstraction becomes raw bytes your code can actually operate on.


What the Quality Setting Actually Means

The quality option you pass to lossy encoders like encodeWebp and encodeAvif controls the trade-off between file size and image fidelity. It’s worth understanding what’s actually happening rather than treating it as a dial you copy from a Stack Overflow answer.

Both WebP and AVIF are transform-based codecs. They don’t store every pixel value independently; they apply mathematical transforms that convert the pixel grid into a representation of spatial frequencies.

High-frequency detail, (sharp edges, fine texture, grain), is encoded with less precision at lower quality settings, because the human visual system is far less sensitive to this than to low-frequency content like broad colour gradients. When you reduce quality, you’re telling the encoder: discard fidelity in the areas the eye is least likely to notice.

The relationship between quality and file size is not linear. Going from quality 60 to 80 produces a noticeable perceptual improvement. Going from 80 to 90 is much subtler, while the file size increase is substantial.

Starting points that work for most photography

For @jsquash/webp, quality 80 is widely regarded as the perceptually transparent threshold and most viewers cannot distinguish it from the original in normal contexts. For @jsquash/avif, that threshold sits around 60 to 70, because AVIF’s codec achieves better quality per bit. These are starting points; you will tune in practice based on your content, but they’ll get you to a good result immediately.


The Lazy-Load Imperative

There is one significant pitfall with @jsquash that you need to internalise before writing any component: WASM modules are large. @jsquash/avif bundles a .wasm binary that is several hundred kilobytes. Importing it at the top of a file the way you’d import a utility function causes the browser to fetch and instantiate that binary during the initial page load, that is, before the user has touched anything.

The symptom is a heavier initial load, or in bad cases a perceptible freeze while the WASM binary instantiates on the main JavaScript thread. The fix is to import the codec modules lazily, inside the async function that needs them, triggered only when the user actually provides an image.

Never put @jsquash imports at the top of a file

Static top-level imports cause every visitor to pay the WASM download cost, even those who never interact with the image optimizer. Always import codec modules dynamically, inside the async function that calls them.

// src/lib/image/convert.js

// Avoid: top-level static import - WASM binary fetched on every page load
// import { decode } from '@jsquash/jpeg'
// import { encode } from '@jsquash/webp'

// Preferred: dynamic import inside the async function
export async function jpegToWebp(file) {
	// These resolve on the first call to this function, not when the module loads.
	// After the first call, both the JS module and the compiled WASM binary are
	// cached by the browser - subsequent calls within the same session are instant.
	const { decode } = await import('@jsquash/jpeg')
	const { encode } = await import('@jsquash/webp')

	const sourceBuffer = await file.arrayBuffer()
	const imageData = await decode(sourceBuffer)
	const webpBuffer = await encode(imageData, { quality: 80 })

	return new Blob([webpBuffer], { type: 'image/webp' })
}

The first call fetches and instantiates the WASM binaries, with a delay proportional to the user’s connection speed. Every subsequent call within the same session is fast, and both, the JS module and the compiled binary, are cached by the browser. The user experiences a small one-time cost on their first compression, and nothing noticeable after that.

This lazy-loading pattern connects directly to how you’ll manage state in following lessons. The Svelte 5 optimizer will track a distinct 'loading-codec' phase while the binary is being fetched for the first time, separate from the 'processing' phase once encoding begins. Understanding that the first import is fundamentally different from subsequent ones makes that state design feel obvious rather than invented.


@jsquash vs ImageMagick vs Sharp: Choosing the Right Tool

Both client-side WASM and server-side processing are genuinely useful. The right choice depends entirely on where the images come from and what you’re doing with them.

ScenarioBest tool
User uploading their own photos@jsquash client-side
Privacy-sensitive images (medical, legal, personal)@jsquash client-side
Edge or serverless - no persistent runtime@jsquash client-side
Static site build: optimising your own assetsvite-imagetools or sharp at build time
Batch processing thousands of files overnightsharp server-side
Re-processing an existing corpus of stored filessharp or ImageMagick server-side
Trusted, pre-optimised images from a CMS CDNSkip encoding entirely

The Key

The key distinction is the origin of the image. If the image comes from a user in response to a user action, client-side WASM is almost always the right call. If the image is one of your own assets being prepared at build time, it belongs in a build-time pipeline. If you’re running unattended batch work on a large corpus of existing files, server-side tools are the better fit - they can saturate all available CPU cores without browser constraints and without paying the WASM performance overhead.

Different Problems, Different Solutions.

The fact that @jsquash can do things ImageMagick can’t, like compressing an image that lives on the user’s device without uploading it—is not a weakness of ImageMagick, it’s a strength of @jsquash. Conversely, the fact that ImageMagick can saturate a server’s CPU cores for batch processing is not a weakness of @jsquash, it’s a strength of ImageMagick.

They’re different tools for different jobs, and that’s perfectly fine. @jsquash isn’t trying to replace Sharp or ImageMagick for server-side batch work—it’s solving a different problem: giving browser applications access to professional-grade codec quality without the server infrastructure, bandwidth cost, and privacy exposure of the traditional upload-then-process flow.


What Comes Next

You now have the conceptual foundation for everything that follows. @jsquash is a set of WASM-compiled professional codecs wrapped in a consistent JavaScript API. WebAssembly is compiled native code running in a sandboxed virtual machine that is fast enough for real work at reasonable cost, safe enough to load from npm.

ImageData is the format-neutral bridge that makes any source codec interoperable with any target codec. Quality settings control the precision of high-frequency detail. And WASM modules must be imported lazily to avoid penalising every page visitor who may never compress a single image.

In following lessons, all of this becomes tangible. You’ll build a working image compressor in Svelte 5: a drag-and-drop component that accepts any image, runs it through @jsquash, and shows a live before/after slider with real-time file-size stats.

You’ll see how $state tracks the compression lifecycle, how .svelte.js modules keep stateful logic separate from the template, and how URL.createObjectURL creates zero-cost image previews without a single network request. The @jsquash API won’t surprise you there, because you’ve already seen its shape. The interesting part of the next lesson is how Svelte 5’s reactivity model makes the UI feel like a natural consequence of the state.


Key Takeaways

  • @jsquash is not a new codec - it’s a collection of WASM-compiled packages wrapping existing professional libraries (MozJPEG, libwebp, libavif, the Rust PNG crate, and more). Note that @jsquash/png and @jsquash/oxipng are separate packages with different roles: the former handles full PNG encode and decode, the latter is an optimiser for existing PNGs using OxiPNG.
  • Every codec follows the same two-step contract: decode(buffer) turns compressed bytes into an ImageData pixel grid; encode(imageData, options) turns that pixel grid into a compressed format. ImageData is the format-neutral bridge that makes every codec interoperable with every other.
  • Never put @jsquash imports at the top of a file. Static imports cause the WASM binary to download and instantiate on page load, degrading performance for every visitor even if they never interact with an image. Always import inside the async function that needs them.
  • Client-side processing is the right default when images come from users: zero server CPU cost, 80 to 90 percent less upload bandwidth, and raw EXIF location data never reaches your infrastructure accidentally. For server-side batch work on your own assets, ImageMagick and Sharp remain the right tools.

Further Reading

See Also