Image upload and resize
Client-side image resize with size and type guards
For when the answer to “People upload photos or images” is yes.
Rebuilt in ~11/20 projects, and it is the direct cause of store-kit's quota finding: a phone photo is 4-8MB, base64 inflates it ~33%, localStorage caps near 5MB.
Built by hand again in 11 of 20 audited projects before this existed. Each rebuild was another chance to make the mistake below.
Skip the rebuild
You do not write this one. It arrives in your project as ordinary source you can read, change and keep, with its audit record and its caveats beside it.
- Download the Playground. It is free and runs on your own machine.
- Start a new app and tick “People upload photos or images”.
- This block is written in, along with anything else you ticked.
The mistake it removes
The naive version decodes whatever it is handed. A 100MB image is fully rasterized into memory before anyone checks it — that is a tab crash on a phone.
What is already handled
Each of these was checked by running the code, not by reading it.
- 37 browser assertions (counted here: test/img-util.test.mjs) in a real headless Chromium, all passing — this block's own browser leg drives the shipped vanilla/img-util.js unmodified, and each line below is asserted by name in that suite on every run of the block gate. Until ph0tsk596 these sentences were a property of one 2026-07-17 DD run elsewhere and nothing here re-ran them.
- byte-size check runs BEFORE decode — an oversized file never reaches createImageBitmap
- decompression bomb refused: a 5000x5000 flat-color PNG is under 2MB on disk (so maxInputBytes would pass it) but is rejected by maxPixels before the canvas is allocated — verified with the real file, including that the byte guard alone would NOT have caught it
- a 4000x3000 (12MP) camera-sized photo still passes the pixel guard
- MIME allowlist rejects unsupported types with a message naming the actual type
- downscale only: a 100x50 source with maxDim 1024 returns 100x50, not an upscaled blur
- aspect ratio preserved (2000x1000 -> 512x256); a 3000x2 sliver still floors at >= 1px
- objectURL revoked in a finally block, so it is released even when decode throws; ImageBitmap closed on every exit path including the guards
- undecodable bytes fail with a human message, not 'undefined' or '[object Object]'
- quality steps down while over maxOutputBytes, then throws rather than returning something oversized
- reported bytes agree with dataUrlBytes(); a 3000x2000 'phone photo' resizes to well under a localStorage-sized budget
- an engine that cannot encode the requested format and silently hands back PNG is caught by reading the data URL's own media type: the image is re-encoded as JPEG, the quality loop still shrinks it, result.format reports what was really produced, and a 3000x2000 noisy photo is not refused under the 2MB default (driven by simulating that engine in Chromium)
What the audit found
Named rather than summarized. The reasoning behind each one ships inside the block, so it travels with the code instead of living on a page you have to trust.
- Unsupported output format falls back to PNG silently: quality loop goes inert, result.format lies, and a detailed photo is refused major · Correctness
- Options are spread over defaults with no shape check, so an undefined or non-numeric option silently disables a guard or returns a non-image minor · Correctness
- The DD record the README prints PASS from predates the current suite and disagrees with dd.json on the assertion count minor · Claims match behaviour
- User-facing error messages carry em dashes, against the repo writing rule minor · Production readiness
What you still have to do
A copied file cannot own your secrets, your host or your legal obligations. This is the part the block deliberately does not claim.
- Independent server-side validation of anything you upload. file.type is client-supplied, so the MIME allowlist here is a UX guard and not a security boundary — re-check the bytes on the server before you store or serve them.
- A decoder that parses the image header BEFORE decode — server-side sharp, or a native pipeline — if you need a hard guarantee against a decompression bomb. The pixel guard here runs after one bitmap has already been allocated: it stops the second allocation and turns a tab crash into a readable message.
- Browser-only: needs canvas and either createImageBitmap or Image. There is no Node path — resize server-side with sharp if you need one.
- When the browser cannot encode the requested format, the result comes back as JPEG and result.format says so. JPEG has no alpha channel, so transparent pixels turn black on that path.
- GIF is accepted but the result is a single flattened frame; animation is lost. That is inherent to canvas, not a bug to fix here.
- EXIF is dropped, which strips GPS coordinates from photos — usually the behavior you want. It also drops orientation: createImageBitmap applies EXIF rotation in current browsers, but the Image fallback path does not, so an old browser may render a rotated phone photo.
What lands in your project
vanilla
- vanilla/img-util.js
resizeImage(file, { maxDim, format, quality, maxInputBytes, maxOutputBytes, maxPixels }) -> { dataUrl, width, height, bytes, format }. Also exports dataUrlBytes(dataUrl). Framework-free — works as-is in React.
What you supply
- maxDim — longest edge in px (default 1024)
- format — image/webp default — roughly 30% smaller than jpeg at equal quality
- maxInputBytes — reject before decode (default 25MB)
- maxPixels — reject by DECODED size (default 40MP) — bytes cannot see a decompression bomb
- maxOutputBytes — cap the result (default 2MB)
Licensed MIT. It is a starting point, not a finished product.
Get it
Download the Playground See the other blocks
Nothing here is locked. The files are yours, in your folder, under a permissive license.
Questions
- Is Image upload and resize audited?
- It was audited on 2026-09-29, and the verdict was not a clean pass.
- What do I still have to do myself?
- Independent server-side validation of anything you upload. file.type is client-supplied, so the MIME allowlist here is a UX guard and not a security boundary — re-check the bytes on the server before you store or serve them.
- How do I get this code?
- Download the Playground, start a new app, and tick “People upload photos or images”. The block is written into your project as ordinary source you can read and edit.
All pre-built code blocks · Learn to build from zero · The coding guide