We built the same converter twice, and the two disagree
This site converts images two different ways. JPG, PNG, WebP and SVG going to JPG, PNG or WebP are converted by your own browser and never leave your machine. Everything else is uploaded and converted by a server running sharp. One button, two completely separate implementations, and until this week nobody had measured them against each other. The speed result was the one I expected. The other three were not, and one of them means the same conversion currently gives you a different picture depending on which half ran it.
What the browser half actually buys
The honest case for converting in the browser is not that canvas is a better encoder. It is that the fastest upload is the one you do not make. Conversion time is measured in milliseconds either way; upload time is measured in seconds, and it is paid up front before any work starts.
Bar length is seconds of upload at the stated uplink speed, calculated from the file size. Upload is the only part of the server path that can be measured honestly without knowing the user's connection, and it is the part the browser path removes entirely.
Fifteen seconds of staring at a progress bar on a weak mobile connection, for a job the machine in your hand finishes in under a tenth of a second. That is the whole argument, and it is why the split exists. It also happens to mean the file never leaves the device, which is the claim on the homepage and the reason it is worded as most image conversions rather than nothing is ever uploaded.
Encode speed is close, and not always in the direction you would guess
| Target | Server (sharp) | Browser (canvas) |
|---|---|---|
| PNG | 175 ms | 143 ms |
| JPG | 71 ms | 97 ms |
| WebP | 2,455 ms | 449 ms |
Server timings from sharp on an M1 in Node; browser timings from performance.now() around the canvas calls in a real headless Chrome running the same code the app runs. Both machines are the same machine, which is the only way to compare the encoders rather than the hardware.
The WebP row is not an encoder being clever. It is the two halves being configured differently, which turns out to be the theme of this whole article.
The three ways the outputs disagree
Here is the part I did not expect. “Convert this PNG to JPG” is supposed to be one operation with one answer. It is not.
1. WebP: one path is lossless and the other is not
The server encoded WebP losslessly. The browser encoded it at quality 0.92. Both decisions were deliberate and both are defensible in isolation, and the result was a size difference that did not even point the same way twice:
| Source | Server WebP | Browser WebP | Difference |
|---|---|---|---|
| Photograph | 1,896 KB lossless | 1,025 KB at 43.4 dB | 46% smaller |
| Flat colour | 9 KB lossless | 24 KB at 38.4 dB | 177% larger |
| Transparency | 12 KB lossless | 26 KB at 25.9 dB | 123% larger |
PSNR computed by decoding each result back to pixels and comparing against the source. Lossless means byte-identical pixels came back.
On a photograph the browser gives you a file less than half the size, which is what someone converting to WebP almost always wants. On flat artwork it gives you a file nearly three times larger and throws away pixels doing it, because lossy WebP is the wrong tool for hard edges and few colours. The format benchmark found the same thing from the other direction: our lossless default is expensive on photographs and correct on graphics.
2. JPG: the transparent parts come out a different colour
This one is not a size trade-off. JPEG has no alpha channel, so converting a transparent PNG to JPG forces a choice about what colour the transparent areas become. The two halves choose the opposite thing. I checked the actual corner pixel rather than trusting a quality score:
JPG conversion of a transparent PNG, corner pixel: server (sharp jpeg) RGB = (0, 0, 0) black browser (canvas, white fill) RGB = (255, 255, 255) white
The browser side is the one that is right. A white matte is what almost everyone wants and it is what the comment in client-convert.ts says it is for. The server side is sharp's default, which nobody chose. The fix is a flatten onto white before the JPEG encode on the server path, which makes the two agree.
3. PNG: the browser round trip is not quite lossless
PNG is a lossless format, so converting into it should preserve every pixel. On the server it does. Through a canvas, with transparency involved, it does not:
| Path | Pixels identical | Differing bytes | Output size |
|---|---|---|---|
| Server (sharp) | yes | 0 of 320,000 | 3,113 bytes |
| Browser (canvas) | no | 486 of 320,000 | 6,352 bytes |
A 400x200 PNG containing one antialiased circle. The differing bytes are all in the semi-transparent edge pixels.
The cause is that a 2D canvas stores colours premultiplied by their alpha. Drawing an image in and reading it back out means dividing that multiplication back out, and the rounding does not always land on the value you started with. It is 0.15% of the bytes and every one of them is on an antialiased edge, so no human will ever see it. But “PNG is lossless” is a promise this path technically breaks, and it is better written down than discovered.
The size difference in that table is the more practical finding: the browser PNG is twice the server PNG. On a photograph it goes the other way, and by a lot.
Neither encoder is better, which is inconvenient
| Source | Server PNG | Browser PNG | Winner |
|---|---|---|---|
| Photograph | 7,895 KB | 4,886 KB | browser, by 38% |
| Flat colour | 69 KB | 101 KB | server, by 32% |
| Transparency | 55 KB | 116 KB | server, by 53% |
sharp's png() at its default compression level against Chrome's canvas encoder. Neither was tuned for this comparison; both are what the shipping code actually calls.
Chrome's PNG encoder beats sharp's default settings on a noisy photograph by 38%, and loses to it on flat artwork by 32%. That is a real result and an awkward one, because it means there is no version of “always use the better encoder” that is true. What it actually argues for is raising sharp's compression level on the server path, which is a setting we left at its default and never measured until now.
What I am changing
- Flatten onto white before the server JPEG encode, so a transparent source converts the same way on both paths. This is the only one of the three that a user can see.
- Choose the WebP mode from the source rather than from a constant, on both halves, so a photograph gets lossy and a graphic gets lossless regardless of where it was encoded. The benchmark article already flagged this and the disagreement here makes it worse than it looked. Since done. Both halves now measure the entropy of the greyscale histogram and split on the same threshold: a photograph is written at quality 80, a graphic losslessly on the server and at 0.92 in the browser, which is as close as canvas gets because it exposes no lossless WebP mode.
- Measure sharp's PNG compression level instead of shipping the default, now that there is a number saying the default loses to a browser on photographs.
- Leave the canvas PNG alpha rounding alone. It is 0.15% of bytes on antialiased edges, invisible, and the only fix is to stop using the browser path, which would cost every user a real upload to solve a problem nobody can see.
The general lesson, if there is one
Two implementations of one feature will drift, and they will drift silently, because each one is individually reasonable. Every difference above came from a defensible local decision: lossless is a sensible default, white matte is a sensible default, quality 0.92 is a sensible default. Nothing was careless. The problem is that nobody had ever run the same file through both and diffed the answers.
If you have a system with a fast path and a fallback path, the test worth writing is not “does each path work”. Both of ours worked. It is “do they produce the same thing”, and it is the test almost nobody writes.
The harness is scripts/measure-client-vs-server.mjs. It generates its own sources, drives a real Chrome for the browser half, and prints the table above. The repository is private, so this is not something you can clone and re-run today: what is reproducible is the method, and every input it uses is generated rather than personal, so the same script written against the same two encoders should land on the same table. Same as the DOCX article. If you want to see the browser path in action, converting a PNG to JPG or a JPG to WebP never uploads anything, and now you know exactly what it does differently.
Try it on your own files
Most image conversions run in your browser and never leave your device.
Read next
The attachment limit is not the number you were told