---
title: "137,208 Bytes Instead of 1.43 MB After One Conditional"
canonical: https://dxdev.com/blog/ui-encoding-at-upload-boundary/
datePublished: 2026-08-31
---
The upload log for August 29th shows a phone photo landing at 1.43 MB, a JPEG the customer picked at 438 KB on disk.

The crop dialog had shrunk the picture before sending it, and it never told the browser what format to use. `canvas.toBlob()` takes a callback, a mime type, and a quality argument, and the dialog was only passing the callback. Leave the mime type off and the default is `image/png`. PNG is lossless, which is right for a screenshot or a logo with flat color and hard edges. It is wrong for a JPEG photo with continuous tone, where lossless encoding means paying for every pixel of noise the original compression had already thrown away. On this customer's own album images, the same picture cost five to six times more as a PNG than as the JPEG the customer had started with.

So a browser upload path was turning small files big before they ever left the device.

## The ticket looked like something else

It arrived as a second report from an account that had already filed once, for a picker failure that had been fixed separately. The new complaint was that some photos eventually upload after more than five tries. I grepped the network layer for a retry loop, a timeout value, a chunk size, and found nothing, because the retries were the customer. Every failed request was a plain upload timeout on an oversized file. The fix wasn't on the server at all. It was in what the client encoded before it ever opened a connection.

## The first fix made it worse

Before landing on "just pass a mime type," I tried asking the crop canvas for its dimensions twice, once to decide whether to downscale further, once to build the actual output blob. That meant building two full-resolution bitmaps in memory for a single crop, on a phone, the device with the least memory to spare for this kind of mistake. It didn't fix the format problem, since both bitmaps were still handed to `toBlob()` with no mime type, so both still came out as PNG. All it added was a second full-size decode on hardware that was already timing out on the first one. Real cost, no benefit, and the bug was untouched.

## The fix that worked

One conditional: if the source file was a JPEG, encode the output as `image/jpeg` at a fixed quality instead of letting the default win.

I scoped it narrowly on purpose. The album page is the only place that saves the uploaded file under the customer's own filename, so a `.jpg` on disk needed to actually contain JPEG data, not PNG bytes wearing a JPEG extension. A JPEG source has no alpha channel to lose either, so there was no transparency case to protect.

I verified it against the compiled bundle with twelve assertions covering every page and filename combination, then ran a real upload through the dialog. The response was `200 {"success":1}`, `croppedImage.jpg`, `image/jpeg`, 137,208 bytes, for a file that had gone up as 1.43 MB the day before.

## The release train

It should have shipped as a hotfix immediately. Instead it was filed under a ticket type that routes to the weekly release train, which meant a customer who had already reported the same bug twice would have waited two more days for a fix that was already written and verified. Someone on the team caught that before Monday and pushed it out of band as a hotfix, live and confirmed on the customer's account page the same day. Getting hotfix-shaped tickets flagged automatically, instead of relying on a person noticing mid-release, is its own open ticket now.

## What's still open

Two loose ends stayed open past the fix. The banner upload path runs through the same crop dialog and has the identical missing mime type. It's unpatched, because scoping the fix to the album's save-by-filename behavior meant not touching a page where I hadn't checked whether that assumption held. And one of the photos from the customer's account has no file behind its database row at all. That is a separate failure from a separate day, and the encoding fix neither explains it nor touches it.
