Skip to content

Serving images from Supabase Storage without shipping 4 MB avatars

On-the-fly transformation resizes at read time, but it is metered and it fights your cache. When to transform, when to resize on upload, and how signed URLs complicate both.

Supabase Storage5 min readships at docs/solutions/supabase-storage/image-transformation.md

Tags: supabase · storage · images · performance · transformations · caching

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.
  • resize matters. cover fills and crops (what you want for avatars), contain fits inside, fill distorts. 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 explicit width and height (no layout shift, no second optimiser); or
  • store the original and let next/image do 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-control your images come back with, and whether that is compatible with how long you cache the page that references them.