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 what the default backend will not do. No lossy WebP and no AVIF, because there is no pure-Go encoder for either. 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 the job it is given here. Both formats are available on the libvips backend below.
Two backends, and what swapping costs
The pipeline has a swappable backend. The default needs no system libraries; build with -tags vips and it uses libvips instead. Measured on the same 6.08 MB photograph, in the same container:
pure Go (default) libvips (-tags vips)default profile 149.9 KB JPEG 1001ms 33.9 KB lossy WebP 1853msJPEG, forced 141.0 KB 950ms 123.7 KB 1366msAVIF downgrades to JPEG 79.9 KB 7544ms
libvips produces files about 4x smaller and is not faster. It is slower here, on both the default path and a like-for-like JPEG comparison. The often-quoted 4-8x speedup is libvips against ImageMagick, not against Go's native image package, and it did not reproduce. The reason to want libvips is bandwidth and storage, which is the thing that actually costs money.
Note what AVIF costs: 7.5 seconds for one image, and on this photograph it came out larger than lossy WebP. It is worth having for the cases where it wins, but it is not a default and it is not viable on a synchronous upload.
The catch is cgo. grit deploy cross-compiles to linux/amd64 with CGO_ENABLED=0 from whatever machine you run it on, and cgo cannot cross-compile without a target toolchain, so the vips build has to happen where it will run. Docker is that place:
docker build --build-arg IMAGE_BACKEND=vips -f Dockerfile.api .
The same profiles drive both backends. What changes is what Auto resolves to: lossy WebP under libvips, JPEG or lossless WebP under pure Go. A profile asking for AVIF on the pure-Go backend is downgraded rather than refused, so one binary still serves a project whose profiles assume libvips, and the ref records what was really produced. That is what format on the FileRef is for, and the backend is named in the upload log line.
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.
Client-side, which is where it belongs
Uploads go from the browser straight to storage through a presigned URL and never pass through the API. So the optimisation happens on the client, in @repo/upload, before the bytes leave the device. That is not a compromise. Measured in real Chromium on the same 5 MB photograph:
pure-Go server backend 149.9 KBlibvips server backend 33.9 KB (needs cgo)browser, client-side 35.0 KB <- 147x smaller, no server involved
The browser matches libvips, because it has a lossy WebP encoder built in. That is the one thing pure Go could not do without cgo, and it turns out to have been on the client the whole time. Nothing is spent on server CPU or server bandwidth, and on a phone the 5 MB never leaves the handset.
import { createUploader, createAxiosTransport } from "@repo/upload";import { optimizeImage } from "@repo/upload/web";import { apiClient } from "@/lib/api-client";export const uploader = createUploader({transport: createAxiosTransport(apiClient),optimize: optimizeImage,});
The optimiser is injected rather than imported, so the same uploader works on Next.js, a Vite SPA and Expo without any bundler resolving a platform. Expo swaps in @repo/upload/expo, which uses expo-image-manipulator because React Native has no canvas. React apps get useUpload from @repo/upload/react, with per-file progress and describeSaving() for the "6.1 MB to 41 KB" label.
Profiles come from GET /api/v1/media/profiles, so the client uses the server's numbers rather than its own copy of them. Two copies drift the first time one changes.
The admin's dropzone already uses this. Every file field in a generated project optimises before it uploads, with no wiring on your part: measured in the browser, a 2.53 MB photo dropped on a form asks to upload 64 KB and a 3.5 KB thumbnail, both WebP.
There is a Grit UI block for it too, if you want the dropzone rather than the hook. It shows the saving as it happens, keeps a failed file in the list with the reason instead of letting it vanish, and puts a real file input behind a label so keyboard activation and the mobile picker come for free:
npx shadcn@latest add https://ui.gritframework.dev/r/application-ui-file-upload-optimizing-dropzone.json
Using it outside Grit
Inside a Grit monorepo the package is a workspace dependency, resolved from disk, so there is nothing to install and no registry involved. For a React app that is not a Grit project, it is on npm:
npm install @gritframework/uploadnpx expo install expo-image-manipulator # Expo only
It has no dependency on Grit. What it needs is three endpoints your API provides: GET /media/profiles, POST /uploads/presign and POST /uploads/complete. The README documents the shapes, including the two things a presign endpoint should do once the client is the one optimising.
The published package and the copy in your project are the same source: the scaffolder embeds packages/upload from the Grit repository rather than keeping its own template, so the two cannot drift.
What the server does once it stops doing the work
A presigned URL is a capability handed to a browser, so the server can no longer guarantee what landed in the bucket. Its job becomes constraining and verifying rather than transforming, and two things do that.
The exact byte count is signed into the URL. The client optimises first, so it knows the size before it asks, and S3 rejects a PUT of any other length. Without that the URL is an unbounded write capability: ask to upload two megabytes, send five gigabytes, and nothing on the server side ever sees it happen.
The completion call re-reads the object from storage. Every number in that request is a claim, since the bytes never came through the API. Believing the reported size would make every storage total in the admin fiction. It now asks the bucket, and deletes anything over the limit.
The server pipeline is still there
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.
Decompression bombs are refused
A solid-colour PNG compresses to almost nothing whatever its dimensions, so an upload that passes every file-size check on the way in can still be enormous once decoded. Measured: a 165 KB file at 12000x12000 allocated 224 MB, and ten concurrent uploads of it would have been 2.2 GB.
The dimensions are read from the header before any pixels are allocated, and anything over MaxPixels is refused. The default is 50 megapixels, which passes a 48 MP professional camera frame and refuses the bomb.
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.
