Your profile page loads a 48-pixel avatar. The network tab says 4.2 MB. The user uploaded a photo straight from their phone, you stored it as-is, and the browser is downloading twelve megapixels to draw a circle the size of a fingernail.
Multiply by every avatar in a list view and the page is unusable on mobile, while your storage egress bill grows for bytes nobody can perceive.
Three ways to fix it, and how to choose
1. Transform on read. Ask storage for a resized version at request time. Zero upload complexity, works for images already stored, and it is metered.
2. Resize on upload. Generate the sizes you need once, store them, serve them directly. No per-read cost, no vendor lock-in, more code and a job to run.
3. Do neither, and constrain the input. For avatars, crop in the browser before upload. The 4 MB never exists.
Most apps want 3 for avatars and 1 for everything else. Reach for 2 when you serve a lot of images to a lot of people and the transformation meter starts showing up on the invoice.
Transform on read
import { getSignedDownloadUrl } from "@/lib/storage";
const avatarUrl = await getSignedDownloadUrl(user.avatarKey, {
expiresIn: 900,
transform: { width: 128, height: 128, resize: "cover", quality: 75 },
});
Supabase resizes on the way out, caches the result at its CDN, and serves WebP or AVIF when the browser accepts it. The 4 MB photo becomes about 8 KB.
Things worth knowing before you sprinkle this everywhere:
- It is billed per origin image per month, not per request. A hundred sizes of one image count once; one size of a hundred thousand images counts a hundred thousand times. Cheap for avatars, surprising for a gallery.
- It is a paid-plan feature. On the free tier the parameters are ignored and you silently serve the original, which is exactly the bug you were fixing, so check the byte count rather than assuming.
resizematters.coverfills and crops (what you want for avatars),containfits inside,filldistorts. Pick deliberately.- Ask for the size you render, times the device pixel ratio. A 128 px avatar on a 2× screen wants 256 px. Requesting one size and scaling in CSS wastes either bytes or sharpness.
Signed URLs fight your cache
This is the part that catches people.
A signed URL contains a token and an expiry. Two consequences:
- The URL changes on every render. Different URL, different cache key: the browser and the CDN both re-fetch an image they already have. A list of fifty avatars re-downloads all fifty on every page load.
- A cached page outlives its URLs. Cache a server-rendered page for an hour with fifteen-minute URLs in it and, forty-five minutes in, everyone gets broken images.
Ways out, in order of preference:
Serve genuinely public images from a public bucket. An avatar that appears next to a public comment is not private. Put it in a separate public bucket, get a stable URL, and let the CDN and the browser cache it properly. This is the right answer more often than people expect.
Match the cache lifetime to the URL lifetime. If a page holds signed URLs, its own cache must be shorter than the shortest URL in it. Long-lived URLs are not the fix: a URL that lives a week is a week-long unauthenticated grant.
Sign per request, render dynamically. Correct, and it means the page cannot be static. Fine for a dashboard, expensive for a marketing page.
Proxy through your own route. A stable app URL that checks the session and redirects to a fresh signed URL. You get caching under your control and a permission check per request, at the cost of a hop.
Resize on upload
When reads are heavy and predictable, do the work once:
import sharp from "sharp";
import { putObject } from "@/lib/storage";
export async function generateThumbnails(key: string, original: Buffer) {
for (const width of [128, 512]) {
const resized = await sharp(original).resize(width, width, { fit: "cover" }).webp({ quality: 78 }).toBuffer();
await putObject({
key: key.replace(/(\.[a-z0-9]+)$/i, `-${width}$1`).replace(/\.[a-z0-9]+$/i, ".webp"),
body: resized,
contentType: "image/webp",
cacheControl: "public, max-age=31536000, immutable",
});
}
}
Run it from a background job, not from the request that finished the upload:
sharp on a 12 megapixel image takes real CPU and the user is waiting.
Store the derived keys alongside the original so a render never has to guess which sizes exist.
Next.js <Image> and remote sources
next/image optimises remote images, but every remote host must be allowed in
next.config.ts, and its optimiser is billed per source image on Vercel, so
you can end up paying two optimisers to do one job. Either:
- let Supabase transform and render a plain
<img>with explicitwidthandheight(no layout shift, no second optimiser); or - store the original and let
next/imagedo everything, with no transform parameters.
Doing both is a common accident. Check the network tab: if the URL contains both
/render/image and /_next/image, you are paying twice.
What to check
- Open a page with avatars and read the transferred size. A 128 px avatar should be single-digit kilobytes.
- Reload. If every image re-downloads, your URLs are changing per render.
- Look at the response
content-type. Modern browsers should get WebP or AVIF. - On a paid plan, confirm the transformation actually applied: the free tier ignores the parameters and serves the original.
- Check what
cache-controlyour images come back with, and whether that is compatible with how long you cache the page that references them.