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, so a size cap wasn’t the issue. It just 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, and wrong for a JPEG photo with continuous tone, where lossless encoding just 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 she’d started with.
That’s a browser upload path turning small files big before they ever leave the device.
The ticket looked like something else at first. 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 “some photos eventually upload if I try more than five times.” Grep the network layer for a retry loop, a timeout value, a chunk size, and there’s nothing to find, because the retries were the customer, not the code. Every failed request was a plain upload timeout on an oversized file, and the fix wasn’t on the server at all. It was in what the client encoded before it ever opened a connection.
Before landing on “just pass a mime type,” I tried fixing it by 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, which is the device with the least memory to spare for exactly this kind of mistake. It didn’t fix the format problem at all, since both bitmaps were still handed to toBlob() with no mime type, so they 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. Wrong turn, no benefit, real cost, and it didn’t touch the actual bug.
The real fix was 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. Scoped 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. Verified against the compiled bundle with twelve assertions covering every page and filename combination, then a real upload through the dialog: 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.
It should have shipped as a hotfix immediately. Instead it got filed under a ticket type that routes to the weekly release train, which meant a customer who’d 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 her 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.
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, unpatched, because scoping the fix to the album’s own save-by-filename behavior meant not touching a page where I hadn’t checked whether that assumption held. And one of the photos from her account never has a file behind its database row at all, a separate failure from a separate day that the encoding fix doesn’t explain and doesn’t touch.