DEV Community

Mohamed Fouad
Mohamed Fouad

Posted on

How I Built Browser-Based Image Tools Without Uploading User Files

I've used plenty of online image tools over the years.

Resize an image.

Compress it.

Convert WebP to JPG.

Convert HEIC to JPG.

Turn a few images into a PDF.

The workflow is usually simple:

Select file → upload it → wait → download the result.

But there was always one thing that bothered me:

Why does a simple image operation require sending the user's file to a server?

For many image-processing tasks, the browser already has enough capabilities to do the work locally.

So I decided to build a small experiment around that idea.

That experiment became Pixyro.

Pixyro

The basic idea

The architecture is intentionally simple:

User selects an image
        ↓
Browser reads the file
        ↓
Image is decoded locally
        ↓
Processing happens in the browser
        ↓
Result is generated locally
        ↓
User downloads the result
Enter fullscreen mode Exit fullscreen mode

There is no image-processing API sitting behind the upload button.

The image doesn't need to travel to my server just to be resized or compressed.

That sounds obvious, but building it reliably across browsers is where things get interesting.


Browser APIs are more capable than I initially expected

The foundation is the browser's image and canvas APIs.

For example, a typical image workflow can involve:

  • File
  • Blob
  • createImageBitmap()
  • Canvas
  • drawImage()
  • canvas.toBlob()

createImageBitmap() can also be used from a Web Worker, which makes it useful when moving heavier image work away from the main UI thread.

That gives us a workflow roughly like this:

const bitmap = await createImageBitmap(file);

const canvas = new OffscreenCanvas(
  targetWidth,
  targetHeight
);

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

context.drawImage(
  bitmap,
  0,
  0,
  targetWidth,
  targetHeight
);
Enter fullscreen mode Exit fullscreen mode

The actual implementation needs considerably more work than this, especially when dealing with different formats, EXIF orientation, browser differences, memory usage, and cancellation.

But the underlying concept is surprisingly small.


Why Web Workers matter

Image processing can become expensive very quickly.

A 4000×3000 image contains:

12,000,000 pixels
Enter fullscreen mode Exit fullscreen mode

And each pixel may require several bytes of memory depending on the representation being used.

If all of that work happens directly on the main thread, the UI can become unresponsive.

So I moved the heavier processing into Web Workers wherever practical.

The simplified architecture looks like:

                Main Thread
                     │
              user interaction
                     │
                     ▼
              processing request
                     │
                     ▼
              ┌─────────────┐
              │ Web Worker  │
              │             │
              │ decode      │
              │ resize      │
              │ crop        │
              │ encode      │
              └──────┬──────┘
                     │
                     ▼
                result Blob
                     │
                     ▼
                Main Thread
Enter fullscreen mode Exit fullscreen mode

This also makes cancellation possible.

If the user starts processing a large image and changes their mind, the worker can be terminated rather than leaving an expensive operation running in the background.


The first problem: "compress to 100 KB" isn't as simple as it sounds

One of the tools I wanted was:

Compress this image to a maximum of 100 KB.

At first glance, this sounds like:

canvas.toBlob(..., 0.5);
Enter fullscreen mode Exit fullscreen mode

But image quality isn't a direct mapping to output file size.

For example:

quality = 80
Enter fullscreen mode Exit fullscreen mode

doesn't mean:

output = 80% of original size
Enter fullscreen mode Exit fullscreen mode

The actual result depends on:

  • image dimensions
  • image content
  • encoder implementation
  • browser
  • output format

So I implemented a search strategy instead.

Conceptually:

Try quality 92
      ↓
Too large?
      ↓
Try quality 40
      ↓
Still too large?
      ↓
Target impossible at these dimensions
      ↓
Otherwise binary-search the quality range
Enter fullscreen mode Exit fullscreen mode

The important part is that the tool measures the actual generated Blob size.

It doesn't estimate the result.

If the user asks for 200 KB, the application checks the actual bytes.


Then I discovered an important edge case

Suppose the image is:

3200 × 2400
123 KB
Enter fullscreen mode Exit fullscreen mode

And the user asks for:

Maximum file size: 200 KB
Maximum dimensions: 1200 × 1200
Enter fullscreen mode Exit fullscreen mode

The file is already below the requested file size.

A naive implementation might say:

Great. We're already below 200 KB. Return the original file.

But that would violate the dimension requirement.

The correct result is:

3200 × 2400
        ↓
1200 × 900
Enter fullscreen mode Exit fullscreen mode

even though the original file was already small enough.

This was actually a bug I found while extending Pixyro.

The fix was conceptually simple:

Return original file only when:

file size <= target
AND
dimensions <= limits
Enter fullscreen mode Exit fullscreen mode

not merely:

file size <= target
Enter fullscreen mode Exit fullscreen mode

This is one of those bugs that is easy to miss if you only test the happy path.


Keeping aspect ratio is another constraint

When applying maximum dimensions, I don't want to:

  • stretch the image
  • crop it
  • add padding
  • change its aspect ratio

So the scale factor is based on the smaller of the two dimension constraints:

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

Then:

const newWidth = Math.round(width * scale);
const newHeight = Math.round(height * scale);
Enter fullscreen mode Exit fullscreen mode

The final implementation also makes sure rounding doesn't accidentally exceed the configured limits.

For example:

Original:
3200 × 2400

Maximum:
1200 × 1200

Result:
1200 × 900
Enter fullscreen mode Exit fullscreen mode

For a portrait image:

Original:
2400 × 3200

Maximum:
1200 × 1200

Result:
900 × 1200
Enter fullscreen mode Exit fullscreen mode

No cropping.

No stretching.

Just proportional scaling.


EXIF orientation makes things more interesting

Modern phones can store an image physically as one orientation while using EXIF metadata to tell software how it should be displayed.

That means the raw dimensions aren't always the dimensions the user thinks they're looking at.

For example:

Stored pixels:
4000 × 3000

Displayed orientation:
3000 × 4000
Enter fullscreen mode Exit fullscreen mode

If you ignore the orientation metadata, you can easily apply the wrong dimension constraints.

So image processing needs to account for orientation before making decisions about:

  • width
  • height
  • crop
  • resize
  • preview
  • output

This is especially important for photos coming directly from mobile devices.


Browser differences are real

Another lesson from building Pixyro:

Don't assume that browser image encoders behave identically.

The same image and nominal quality setting can produce different output sizes in different browsers.

That matters a lot when the user asks for an exact constraint such as:

≤ 200 KB
Enter fullscreen mode Exit fullscreen mode

The application can't simply assume that a quality value produces a particular file size.

It has to inspect the actual result.

This is also why I prefer honest failure over pretending that every target is achievable.


What happens when the target is impossible?

Imagine the user asks for:

10 KB
1200 × 1200 maximum dimensions
Enter fullscreen mode Exit fullscreen mode

But even the lowest acceptable quality at those dimensions produces:

29.8 KB
Enter fullscreen mode Exit fullscreen mode

There are two bad approaches.

Bad approach #1

Keep reducing the dimensions automatically until the file reaches 10 KB.

That violates the user's dimension constraint.

Bad approach #2

Return a file and pretend the target was achieved.

Obviously worse.

Instead, the tool tells the user that the target couldn't be reached at the requested dimensions.

The actual result can still be offered as a secondary option when it is smaller than the original.

The important principle is:

Never hide a failed constraint.

If a requirement wasn't achieved, tell the user.


Privacy was a design constraint, not a marketing feature

I didn't want "privacy" to be something added to the homepage after the application was finished.

It influenced the architecture from the beginning.

For the image-processing tools, the intended flow is:

Your file
   ↓
Your browser
   ↓
Pixyro processing
   ↓
Your browser
   ↓
Your download
Enter fullscreen mode Exit fullscreen mode

Not:

Your file
   ↓
Pixyro server
   ↓
Processing server
   ↓
Storage
   ↓
Your download
Enter fullscreen mode Exit fullscreen mode

This has another advantage besides privacy:

Infrastructure becomes much simpler.

There is no image-processing backend to scale.

No upload storage.

No cleanup jobs for uploaded images.

No queue for ordinary image conversions.

For a free tools website, that matters.


But browser-only doesn't mean "problem solved"

There are trade-offs.

Large images can consume significant memory.

Different browsers have different capabilities.

Some formats require specialized decoders.

Some browsers can't encode certain formats.

For example, WebP output isn't available in every browser environment in the same way, so the application needs to handle unsupported cases explicitly rather than silently changing the requested format.

That's an important UX principle for tools:

Don't silently change the user's requested output.

If they asked for WebP and the browser can't produce WebP, say so.


HEIC was a completely different problem

HEIC files were more complicated than JPEG, PNG, or WebP.

For Pixyro, I didn't want to solve HEIC by simply uploading the image to an external conversion API.

Instead, I built a dedicated WebAssembly-based decoding path.

The architecture becomes:

HEIC
 ↓
HEIC Web Worker
 ↓
WASM decoder
 ↓
decoded pixels
 ↓
existing image pipeline
 ↓
JPEG Blob
Enter fullscreen mode Exit fullscreen mode

The decoder is loaded only when the HEIC functionality is actually needed.

That keeps the normal image tools lightweight.

It also means that a user opening the regular JPEG compressor doesn't need to download a HEIC decoder.


The application became less about "image tools"

This was probably the biggest lesson from the project.

At the beginning, I was thinking:

"I want to build a website with a bunch of image tools."

But that isn't particularly interesting.

There are already hundreds of websites that can:

  • resize images
  • compress images
  • convert formats
  • create PDFs

The more interesting question became:

Can a browser-based tool solve a very specific file requirement correctly?

For example:

Compress this image to ≤ 100 KB.

AND

Keep it below 1200 × 1200.

AND

Don't stretch it.

AND

Don't upload it.

AND

Tell me honestly if the requirement is impossible.
Enter fullscreen mode Exit fullscreen mode

That's much more specific than:

"Compress image."

And I think this is where useful web tools can still differentiate themselves.


Testing became almost as important as the feature

For these tools, a UI test isn't enough.

I need to verify the actual downloaded file.

For example:

Input:
3200 × 2400 JPEG

Configuration:
1200 × 1200
200 KB

Expected:
≤ 200 KB
1200 × 900
JPEG
Enter fullscreen mode Exit fullscreen mode

So the test suite checks things such as:

  • actual output dimensions
  • actual output file size
  • MIME type
  • image orientation
  • aspect ratio
  • transparency behavior
  • cancellation
  • browser differences
  • responsive behavior

For the latest version of the target-size compressor, I ended up testing the production implementation across Chrome, Firefox and WebKit, including mobile browser environments.

That caught a few things that ordinary unit tests alone wouldn't have exposed.


The stack

The application is built with:

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • Web Workers
  • Canvas APIs
  • WebAssembly where necessary
  • Cloudflare Pages

The interesting part isn't really the framework.

The interesting part is keeping the processing architecture:

Browser-first
Worker-based where appropriate
Reusable
Format-aware
Constraint-aware
Privacy-conscious
Enter fullscreen mode Exit fullscreen mode

What I'm building next

Pixyro is now live as a collection of free browser-based tools.

But I'm deliberately not trying to add 50 more tools immediately.

I'm more interested in answering a different question:

What exact file problems are people actually trying to solve?

For example, someone might not really want an "image compressor."

They might need:

"A 3000×2000 photo reduced to less than 200 KB without making the text unreadable."

That's a very different product requirement.

And that's the direction I'm exploring.


Final thoughts

Building browser-based image processing taught me that the hard part isn't putting a file input and a canvas on a page.

The hard part is handling the edge cases honestly.

Things like:

  • EXIF orientation
  • browser-specific encoders
  • large image memory usage
  • exact file-size constraints
  • dimension constraints
  • cancellation
  • unsupported formats
  • privacy
  • and impossible requirements

The browser is capable of doing much more image processing than I originally expected.

And for many use cases, the best architecture might be:

Don't upload the file at all.

Process it where it already exists.

In the user's browser.


I'm building Pixyro as a practical experiment around that idea.

If you're building browser-based file or image processing tools yourself, I'd be interested to hear:

What edge case caused you the most trouble?

Top comments (0)