CLIENT-SIDE / ZERO DEPENDENCIES / MIT

Strip every trace. Keep every pixel.

A photo off a phone carries GPS coordinates, a device serial and a capture timestamp. pixelscrub draws it to a canvas and reads it back — metadata is gone by construction, not by being edited out. The bytes never leave the tab.

$ npm i pixelscrub
TRY IT ON A PHOTO
0 dependencies| runs entirely in the browser| anything with canvas + toBlob

Live demo

SANITIZE — RUNNING LOCALLY, NOTHING UPLOADED

Both previews render with image-orientation: none, so you see the raw pixel grid. A portrait photo that looks sideways as the original and upright once sanitized is the orientation fix doing its job.

Embedded metadata AWAITING A FILE
Drop a photo to read its EXIF.
outputFormat
maxWidth
quality 0.85
IN
OUT
SAVED
METADATA

            
The cost of an untouched photo

Three costs you pay for doing nothing.

Every one of them is fixed in the same pass, in the browser, before a single byte is uploaded.

Privacy and compliance

EXIF can carry GPS coordinates, device model and capture timestamps. Storing that unmodified is a GDPR/CCPA liability you did not ask for — and one nobody notices until an audit.

Stripped: EXIF, GPS, IPTC, XMP, thumbnails, maker notes.

Bandwidth

Phone photos run 5–10 MB each. Uploading those over a weak connection fails often, and costs real money in transfer and storage every time it succeeds.

Typically 90–98% smaller at 1920px WebP.

Server load

Resizing server-side with sharp or imagemagick burns CPU you could avoid entirely. The client already has a decoder and a rasteriser sitting idle.

No queue, no worker pool, no temp files.
How it works

A canvas holds pixels and nothing else.

Draw the image to a canvas, read it back out. Metadata is gone by construction rather than by being edited out — there is nowhere left for it to live. Resizing and format conversion happen in the same pass, for free.

01

Read the orientation

The one tag with visual meaning is parsed off the raw JPEG bytes first, before any decoding.

02

Draw, in halves

Large downscales step down by halving. One big drawImage aliases badly in every engine; the extra draws are cheap.

03

Bake the rotation in

The transform is applied exactly once, to the pixels. The output carries no orientation tag of its own.

04

Encode back out

WebP, falling back to JPEG then PNG where the encoder cannot. You get a File, not a Blob.

The hard part

Browsers disagree about EXIF rotation.

Chrome rotates during decode whatever you ask of it; older engines do not rotate at all. No feature flag tells you which you are on, so assume either and half your users get sideways photos.

pixelscrub settles it by measurement. Once per session it encodes a 2×1 JPEG with the browser's own encoder, splices an orientation-6 tag into it, and decodes it back. A decoder that honours EXIF hands back a 1×2 image.

What survives
The pixels, and a colour profile from the browser's encoder.
A watermark, if you asked for one — drawn into the pixels, so it survives re-encoding and screenshots.
Nothing else. JPEG has no alpha, so a transparent source is flattened onto white first.
API surface

Two functions. Nine options. No config file.

sanitizeImage(file, options?) → Promise<File>
OptionTypeDefaultNotes
maxWidthnumberno limit Bounds the displayed width, after orientation is applied.
maxHeightnumberno limit Aspect ratio is always preserved; a smaller source is never stretched up.
qualitynumber (0–1)0.85 Ignored for image/png. Values outside the range are clamped.
outputFormatwebp | jpeg | pngimage/webp Falls back automatically where the browser cannot encode it.
watermarkWatermarknone Text or a logo, drawn after resizing and rotation.
maxCanvasDimensionnumber4096 Older iOS Safari returns a blank canvas past this, without erroring.
sanitizeImages(files, options?) → Promise<BatchResult[]>
concurrencynumber4 How many images are decoded at once. A memory ceiling, not a speed dial.
signalAbortSignal Cancels the run. Finished results are discarded, matching fetch.
onProgress(p: BatchProgress) => void Called as each image settles, in completion order.
INVALID_INPUTNot a Blob, not an image MIME type, empty file, or a bad option.
DECODE_FAILEDBytes no decoder could read: truncated, corrupt, unsupported.
ENCODE_FAILEDThe canvas refused to produce a blob.
UNSUPPORTED_ENVIRONMENTNo canvas implementation is reachable at all.

Every rejection is a PixelScrubError carrying one of these codes. An unsupported output format is never an error — it falls back.

sanitizeImages()

One corrupt file is one rejected entry, not a lost batch.

Results are settled, not all-or-nothing, and stay in input order — results[i] always describes files[i]. Pick several photos and watch it run.

concurrency
No batch run yet.
FULFILLED
0
REJECTED
0
IN FLIGHT
0
QUEUED
0
Concurrency is a memory ceiling: a decoded 12MP photo is roughly 48 MB of RGBA, and mobile Safari kills the tab rather than reporting an allocation failure.
watermark

Sizes are fractions of the shorter side, not pixels.

A watermark specified as 24px is either illegible on a thumbnail or enormous on a full-resolution photo, and you rarely control which you are handed. One config reads correctly at both ends.

Drop a photo in the demo above to watermark it here.
size 0.04
opacity 0.80
color
outline — text only
Text is stroked before it is filled. Without it, white text on a snow scene produces literally zero distinguishable pixels.

Scrub it before it leaves the tab.

No build step, no worker, no server. Import one function and hand it a File.

$ npm i pixelscrub