Image Optimisation
Somebody uploads a 6 MB photograph straight off a phone. What you want stored is about 150 KB, correctly oriented, with the GPS coordinates removed and a thumbnail beside it. That happens by default, before the file is written, with no configuration at all.
You do not have to configure anything
Every image upload goes through the pipeline using DefaultProfile(). Measured on a real 6.08 MB camera photo:
6.08 MB 4000x3000 JPEG the upload141 KB 1600x1200 JPEG q82 what gets stored9 KB 400x400 JPEG the thumbnail, alongside it------------------------------------------------------------41x smaller, and the 6 MB file never reaches your public bucket
The defaults, and why each number is what it is:
- Fit inside 1600x1600. Covers a 2x retina display at a typical content width. Storing more is paying to keep pixels no browser will draw.
- Quality 0.82. The point at which JPEG is visually indistinguishable from the source. Below about 0.75, artifacts appear on gradients and skin.
- A 400x400 thumbnail. What an admin table row or a card grid needs at 2x.
- The original is kept, under a private prefix, so a profile change can be replayed later. Private because it is for reprocessing, not for serving.
- EXIF is oriented, then stripped. The one nobody thinks of. Orientation first, or a portrait photo comes out sideways. Stripping second, because a phone photo carries GPS coordinates, and a shop publishing product photos would otherwise publish the seller's home address with them.
The format is chosen per image, not configured
Asking a developer to pick an output format is asking them to get it wrong once. The decision is made from the pixels:
The failure this prevents is a transparent logo encoded as JPEG, which silently gains a black box behind it. Under Auto that cannot happen.
It also explains the one thing people ask for and do not get. There is no lossy WebP and no AVIF, because there is no pure-Go encoder for either, and adding one means cgo. Grit compiles to a single static binary that cross-compiles to any target, and that property is worth more than the last 20% of compression. Measured on a photograph, pure-Go lossless WebP produced 778 KB where JPEG q82 produced 35 KB. It is not a substitute for lossy encoding: it is a PNG replacement, which is exactly what it is used for here.
Profiles, when a field wants something different
Profiles live in internal/media/profiles.go, a file written once and never regenerated, so what you put there survives grit generate and grit upgrade.
func init() {media.Define("product-image", media.Profile{Max: media.Fit(1000, 1000),Quality: 0.8,Renditions: map[string]media.Size{"thumb": media.Fill(300, 300),"card": media.Fit(600, 600),},})media.Define("avatar", media.Profile{// Fill, not Fit: a portrait shown in a round frame should crop,// not letterbox.Max: media.Fill(400, 400),Renditions: map[string]media.Size{"thumb": media.Fill(80, 80)},DiscardOriginal: true,})}
Name it from the upload:
POST /api/v1/uploads?profile=product-image
A field you leave zero keeps the default, so a profile that only wants a different size says only that. An unknown or misspelled name falls back to the default rather than failing the upload, because a stale profile name in a deployed client build should degrade, not break.
Note DiscardOriginal rather than a KeepOriginal that defaults to true. A Go bool cannot distinguish false from not-set, so a keep-flag would have silently discarded originals for every profile that did not mention it, while the documentation promised the opposite. The negative phrasing makes the zero value the recommended behaviour.
What lands on the record
{"url": ".../uploads/2026/08/photo-178761052.jpg","name": "big-photo.jpg","mime": "image/jpeg","size": 144333,"width": 1600, "height": 1200,"format": "jpeg","optimised": true,"thumbnail_url": ".../uploads/2026/08/photo-178761052-thumb.jpg","original_key": "originals/2026/08/178761052-big-photo.jpg","original_size": 6375170,"renditions": {"thumb": { "url": "...", "width": 400, "height": 400, "size": 9135 }}}
format is recorded rather than inferred from the URL, so a client never has to guess what it actually received. optimised is false when the transform failed and the file was stored as it arrived, which is the default failure policy: losing somebody's upload because an encoder choked is worse than storing a large file. Set OnError: media.Reject on a profile to refuse those instead.
It happens before the file is stored
The transform is synchronous, which costs roughly half a second to two seconds on a large photograph, most of it decoding and resampling rather than encoding. That cost buys two things.
The 6 MB file never lands in your public bucket, which is the entire point. And the record is only ever written with final URLs.
The version this replaces did it the other way around. It stored the original, queued a job, and returned a reference whose thumbnail field was still empty because the worker had not run yet. That reference is what got written into the record, so every thumbnail Grit generated for a resource file field was orphaned: produced, paid for, and referenced by nothing. Doing the primary transform inline is what fixes it.
Presigned uploads go straight from the browser to S3 and never pass through your server, so they cannot be transformed this way. Use the multipart endpoint for fields that want optimisation.
What is not optimised
- GIF, deliberately. Decoding one keeps the first frame only, so optimising an animation would silently throw it away.
- SVG is rejected at upload rather than optimised. It is a stored-XSS vector and always was.
- PDF and video. The shape generalises, the implementation does not: both need external binaries, and neither fits in a static Go binary.
