Skip to main content

Command Palette

Search for a command to run...

Building a Client-Side Image Compression Pipeline with Web Workers

Building a Client-Side Image Compression Pipeline with Web Workers

Updated
•11 min read•View as Markdown
Building a Client-Side Image Compression Pipeline with Web Workers
M
Full Stack Developer & SaaS Systems Architect Building scalable systems from backend to frontend

Image compression sounds like a simple problem.

Take an image:

photo.jpg

and make it smaller.

In practice, once you add real requirements, things become considerably more interesting:

The processing should happen in the browser.
The original file should not be uploaded.
The UI shouldn't freeze while processing large images.
The user may want a maximum file size such as 100 KB or 200 KB.
The user may also need maximum dimensions.
The aspect ratio must be preserved.
EXIF orientation needs to be handled correctly.
Different browsers may produce different encoded file sizes.
The operation should be cancellable.
If the requested target is impossible, the application should say so instead of pretending it succeeded.

I recently built this kind of pipeline for Pixyro, a collection of browser-based image tools.

This article focuses on the technical side of the implementation.

The architecture

The main principle is simple:

Process the image where it already exists: in the user's browser.

The high-level flow looks like this:

User selects image
        ↓
Validate file
        ↓
Read image dimensions/orientation
        ↓
Calculate target dimensions
        ↓
Send processing job to Web Worker
        ↓
Decode image
        ↓
Draw onto canvas
        ↓
Encode output
        ↓
Measure actual Blob size
        ↓
Return result
        ↓
User downloads file

There is no image upload API involved in this workflow.

The browser performs the processing locally.

Why use a Web Worker?

Image processing is CPU-intensive.

Consider a 4000 × 3000 image:

4000 × 3000 = 12,000,000 pixels

Decoding and manipulating millions of pixels can take enough time to make the main thread noticeably busy.

If the work happens directly inside the UI thread, the user may experience:

frozen controls
delayed scrolling
dropped frames
unresponsive buttons

So the processing pipeline uses a Web Worker.

Conceptually:

                Main Thread
                     │
                     │ processing request
                     ▼
              ┌──────────────┐
              │  Web Worker  │
              │              │
              │   decode     │
              │   resize     │
              │   draw       │
              │   encode     │
              └──────┬───────┘
                     │
                     │ result
                     ▼
                Main Thread

The main thread remains responsible for the interface while the worker handles the expensive processing.

Decoding the image

Modern browsers provide useful primitives for image processing.

One of them is createImageBitmap().

A simplified example:

const bitmap = await createImageBitmap(file);

Once decoded, the bitmap can be drawn onto a canvas.

For a worker environment, OffscreenCanvas can also be used where supported:

const canvas = new OffscreenCanvas(
  width,
  height
);

const context = canvas.getContext("2d");

context.drawImage(
  bitmap,
  0,
  0,
  width,
  height
);

The real implementation needs additional handling around browser compatibility, memory, orientation, cancellation, and output formats.

But this is the core processing primitive.

Compression quality does not equal file size

This was one of the first things that became important.

Suppose we encode a JPEG with:

canvas.toBlob(
  callback,
  "image/jpeg",
  0.8
);

It is tempting to think:

quality = 80%

means the output will be roughly 80% of the original size.

It doesn't.

The output size depends on many things:

image dimensions
image content
JPEG encoder implementation
browser
quality setting
image complexity

Therefore, a target-size compressor cannot reliably calculate:

desired size → quality

using a fixed formula.

Instead, we need to encode and measure the actual result.

Searching for a quality that satisfies the target

Suppose the user asks:

Make this image smaller than 200 KB.

The pipeline can approach this as a search problem.

A simplified strategy is:

Try quality 92
      ↓
Is the result ≤ target?
      │
     yes → done
      │
      no
      ↓
Try quality 40
      ↓
Is the result still > target?
      │
     yes → target may be impossible
      │
      no
      ↓
Binary search between 40 and 92

Every attempt measures the actual Blob:

const blob = await encode(...);

const actualBytes = blob.size;

The important part is that the final decision is based on the generated file, not an estimate.

Why binary search?

Suppose:

quality = 92 → 480 KB
quality = 40 → 130 KB

and the target is:

200 KB

We don't need to test every possible quality value.

We can search the interval:

40 ───────────────────────────── 92

Try the middle:

66

Then use the resulting file size to decide which half of the interval contains the useful range.

Eventually we converge on a quality that satisfies the target.

This makes the number of encoding attempts predictable.

But file size isn't the only constraint

This became an important product requirement.

Imagine this image:

3200 × 2400
123 KB

The user asks for:

Maximum file size: 200 KB
Maximum dimensions: 1200 × 1200

The file already satisfies the file-size requirement.

But it violates the dimension requirement.

A naive implementation might do:

if (file.size <= targetBytes) {
  return file;
}

That would be wrong.

The correct condition is closer to:

if (
  file.size <= targetBytes &&
  dimensionsFit
) {
  return file;
}

Otherwise, the application can return a file that violates one of the user's explicit constraints.

Calculating proportional dimensions

When maximum dimensions are provided, the image should never be enlarged.

For an original image:

width  = 3200
height = 2400

and:

maxWidth  = 1200
maxHeight = 1200

we calculate:

const scale = Math.min(
  maxWidth / width,
  maxHeight / height,
  1
);

For this example:

1200 / 3200 = 0.375
1200 / 2400 = 0.5

scale = 0.375

Therefore:

newWidth  = 3200 × 0.375 = 1200
newHeight = 2400 × 0.375 = 900

Result:

1200 × 900

The important constraints are:

No stretching
No cropping
No padding
No enlargement
Aspect ratio preserved
The interaction between dimensions and compression

This creates an interesting two-stage problem.

The pipeline first determines whether the dimensions need to be reduced.

For example:

Original:
4000 × 3000

Maximum:
1200 × 1200

The image becomes:

1200 × 900

Then the encoder searches for a quality that satisfies the file-size target.

Conceptually:

Original image
      ↓
Dimension constraint
      ↓
1200 × 900
      ↓
JPEG encoding
      ↓
Quality search
      ↓
≤ target size

This separation makes the behavior predictable.

What if the target is impossible?

This is one of the most important parts of a file-processing tool.

Suppose the user requests:

Maximum size: 10 KB
Maximum dimensions: 1200 × 1200

After resizing to:

1200 × 900

the smallest acceptable encoded result might still be:

29.8 KB

At this point, there are two possible approaches.

Approach 1: Keep shrinking the dimensions

This might eventually produce a 10 KB file.

But it violates the user's maximum-dimension configuration only if interpreted incorrectly? More importantly, it changes another constraint the user didn't ask us to relax.

Approach 2: Pretend the target was achieved

Obviously unacceptable.

Instead, the application should report:

The requested target could not be reached
at the configured maximum dimensions.

The user can then decide what constraint to relax.

This is a general principle I like for tools:

Never silently violate a user constraint.

EXIF orientation

Images from phones introduce another problem.

An image can have physical pixel dimensions such as:

4000 × 3000

while EXIF orientation tells the viewer to display it as:

3000 × 4000

If dimension calculations ignore orientation, the application can make the wrong decision.

For example, a portrait image may incorrectly appear to be landscape during processing.

Therefore, orientation needs to be taken into account before applying dimension constraints.

The output should also be normalized so that the resulting pixels represent the expected visual orientation.

Browser differences matter

Another lesson from testing this pipeline is that browsers aren't identical image encoders.

The same source image with the same nominal quality value can produce different output sizes in different browsers.

For example, conceptually:

Chrome:
quality 40 → 196 KB

Firefox:
quality 40 → 248 KB

Safari:
quality 40 → 315 KB

The exact numbers depend on the image and browser version, but the underlying issue is real.

This is another reason why the algorithm must always inspect:

blob.size

instead of assuming that a quality setting maps to a particular output size.

Unsupported formats should stay unsupported

Suppose the user asks for:

JPG → WebP

but the browser environment cannot create WebP output.

One tempting solution is:

Return JPG instead.

That is bad behavior.

The user explicitly requested WebP.

Silently returning another format can break their workflow.

Instead, the application should explain that the requested output format isn't supported in that browser.

This principle applies beyond image tools:

Don't silently change the requested output.

Cancellation

Large images can take time to process.

The user may start a compression job and then click:

Cancel

The processing pipeline should actually stop.

With a dedicated worker, one straightforward approach is to terminate the worker:

worker.terminate();

The UI can then return to the settings state.

A new worker can be created for the next job.

This also helps prevent an old processing operation from accidentally updating the UI after the user has started something else.

Memory is a real constraint

Browser-based processing removes server infrastructure, but it doesn't remove resource limitations.

A decoded image can consume considerably more memory than its compressed file size suggests.

For example:

4000 × 3000 = 12 million pixels

At roughly four bytes per pixel for RGBA data:

12,000,000 × 4
≈ 48 MB

And real workflows can require additional buffers and copies.

This is why file-size limits alone aren't sufficient.

A "10 MB image" isn't necessarily cheap to process.

The number of pixels matters too.

Privacy benefits

The architecture has another useful property.

The image-processing operation doesn't require uploading the user's image.

The workflow is:

Local file
   ↓
Browser
   ↓
Web Worker
   ↓
Canvas / WASM
   ↓
Local Blob
   ↓
Download

Instead of:

Local file
   ↓
Upload server
   ↓
Process
   ↓
Store/cache
   ↓
Download

This eliminates an entire category of infrastructure and data-handling requirements.

There is no reason to upload a JPEG just to resize its pixels when the browser can perform the operation locally.

This doesn't mean browser processing is always better

There are still situations where server-side processing makes sense.

For example:

extremely large files
expensive AI models
video processing
collaborative workflows
centralized document processing
operations requiring server-side data

Browser processing is simply another architectural option.

For relatively self-contained image operations, however, it can be surprisingly capable.

Testing the actual output

One of the biggest changes in my testing approach was moving beyond UI assertions.

It's not enough to test:

Click Compress
→ Result appears

We also need to inspect the downloaded file.

For example:

Input:
3200 × 2400 JPG

Configuration:
200 KB
1200 × 1200

Expected:
≤ 200 KB
1200 × 900
JPG

So the test suite checks things such as:

actual output byte size
actual dimensions
MIME type
aspect ratio
EXIF orientation
cancellation
format behavior
browser compatibility

For the current target-size implementation, production QA covered Chrome, Firefox and WebKit, as well as mobile browser environments.

The tests also specifically covered the case where the input was already below the target file size but exceeded the configured dimensions.

That case caught the regression described earlier.

A reusable processing architecture

One of the goals of the project is avoiding a separate implementation for every tool.

Instead, image operations share common processing infrastructure.

Conceptually:

             Image Processing Core
                     │
       ┌─────────────┼─────────────┐
       │             │             │
     Resize         Crop        Compress
       │             │             │
       └─────────────┼─────────────┘
                     │
                 Re-encode
                     │
             Browser / Worker

This makes it easier to add features without duplicating:

validation
decoding
orientation handling
canvas operations
encoding
cancellation
result handling

The tool-specific logic should describe the operation, while the processing infrastructure handles the mechanics.

What I learned

The biggest lesson wasn't about Canvas or Web Workers.

It was about constraints.

A useful image tool isn't just:

"Make this image smaller."

It may actually be:

"Make this image smaller than 200 KB, keep it below 1200×1200, preserve the aspect ratio, don't upload it, and tell me if that combination is impossible."

Once you think about the problem that way, the architecture becomes clearer.

You need:

Constraints
     ↓
Validation
     ↓
Dimension calculation
     ↓
Processing
     ↓
Actual measurement
     ↓
Constraint verification
     ↓
Result

And not:

Button
 ↓
Canvas
 ↓
Done
Final architecture

The resulting pipeline can be summarized as:

                  User
                   │
                   ▼
             Select Image
                   │
                   ▼
            Validate Input
                   │
                   ▼
         Read Dimensions/EXIF
                   │
                   ▼
        Calculate Constraints
                   │
                   ▼
             Web Worker
                   │
          ┌────────┴────────┐
          │                 │
       Decode             Resize
          │                 │
          └────────┬────────┘
                   ▼
                Encode
                   │
                   ▼
             Measure Blob
                   │
                   ▼
          Verify Constraints
                   │
            ┌──────┴──────┐
            │             │
          Success       Impossible
            │             │
            ▼             ▼
         Download      Explain why

This architecture is still relatively small, but it handles a surprisingly large number of real-world cases.

Final thoughts

Browser APIs have reached a point where many image-processing tasks don't necessarily require a backend.

For the right workloads, you can combine:

Canvas
Web Workers
createImageBitmap()
OffscreenCanvas
WebAssembly
Blob APIs

to build useful processing pipelines entirely on the client.

The difficult part isn't getting the first JPEG to compress.

The difficult part is making the tool predictable.

What happens with a rotated phone photo?

What happens when the browser's encoder behaves differently?

What happens when the requested target is impossible?

What happens when the file is already small enough but its dimensions are too large?

Those edge cases are where a simple demo becomes a real product.

That's what I'm exploring with Pixyro: building small browser-based tools that solve specific file-processing problems without requiring the user's files to leave their browser.

If you're building browser-based image processing yourself, I'd be interested to hear what edge cases you've encountered.