devsnack

Listing Photos in Flutter: The Original Is Not the Asset You Serve

A phone camera makes 5 MB files and a feed of thirty listings makes the bill. Resizing before the upload, signing it so the bytes skip your API route, and the orphaned objects nobody ever deletes.

DevSnack8 Sep 2026 · 13 min

Photo upload is the feature that gets built in an afternoon. Pick an image, post it to your API, store the URL on the listing, render it in the feed. It works on the simulator, it works on your phone, and it works in front of the client. Then a few hundred real sellers arrive with real cameras, and the feed takes six seconds to paint on a mobile connection while the storage bill arrives looking like a mistake.

The file a phone camera hands you is not the file your app should upload, and neither of them is the file your feed should serve. There are three sizes in a working image pipeline and most implementations have one, which is the original, sent everywhere.

This is about the bytes: where they get smaller, how they travel, and which ones you pay for twice. Where the database rows live and what Firestore costs to read is a different post and none of the numbers below are those numbers.

A twelve-megapixel photo is not a thumbnail

A current mid-range phone produces a photo between three and six megabytes. That file is roughly 4,000 pixels on its long edge. The card it will be displayed in is about 400 pixels wide on a phone screen, and even at a three-times device pixel ratio that is 1,200 pixels of actual need. You are moving somewhere around ten times the data the screen can use, and then asking the device to decode all of it.

The decode is the part people miss while they are looking at the network tab. A 4,000-pixel JPEG expands to something like sixty megabytes in memory once decompressed. Do that for the eight cards visible in a scrolling feed and mid-range Android devices start dropping frames and, eventually, being killed by the system — a jank complaint and an out-of-memory crash that both trace back to an upload decision made months earlier.

Resize on the device, before the upload

Every byte you remove before the upload is a byte you do not pay to store, do not pay to serve, and do not make the seller wait for on a mobile connection. This is the cheapest optimisation in the pipeline and it happens in the picker.

lib/images/pick_and_compress.dart
final picked = await ImagePicker().pickImage(
  source: ImageSource.gallery,
  // Resized natively, before the bytes ever reach Dart. Long edge
  // of 1600 is generous for a full-screen detail view and about a
  // twentieth of the original file.
  maxWidth: 1600,
  maxHeight: 1600,
  imageQuality: 82,
);
if (picked == null) return null;

final bytes = await FlutterImageCompress.compressWithFile(
  picked.path,
  minWidth: 1600,
  minHeight: 1600,
  quality: 82,
  // Re-encoding rewrites the file, and an encoder that drops the
  // EXIF orientation tag without rotating the pixels produces the
  // sideways photos every marketplace has a support macro for.
  // Rotate to match, then strip the rest of the metadata below.
  autoCorrectionAngle: true,
  keepExif: false,
);

Dropping EXIF is a privacy decision as much as a size one. A photo taken on a phone carries the GPS coordinates it was taken at, and on a classifieds app that photo was almost always taken in the seller's home. Publishing it unmodified publishes their address to anyone who opens the file in an exif viewer. Strip it on the way in, because you cannot strip it retroactively from a file somebody already downloaded.

Do the resize before you show a preview, not after the user confirms. The preview then displays the file you are actually going to upload, so a seller who thinks the crop looks wrong finds out immediately rather than after a ninety-second upload.

Upload direct, not through your API route

The instinctive design sends the file to your own backend, which then forwards it to storage. It puts your auth check in the path, which is the appeal. It also means every byte is paid for twice — once inbound to your function, once outbound to the bucket — while a serverless function sits there being billed for the duration of a mobile upload.

Then it stops working. Serverless request bodies are capped, and on Vercel that cap is 4.5 MB, which is smaller than the photo a seller took before you compressed it and exactly the sort of limit that only appears once somebody uploads from a good camera. The fix is not a bigger limit. It is to keep the bytes out of your function altogether: your backend signs a short-lived permission, and the device uploads straight to storage.

app/api/listings/[id]/uploads/route.ts
const { data: listing } = await supabase
  .from("listings")
  .select("id, seller_id")
  .eq("id", params.id)
  .single();

// The authorisation happens here, once, on a request with no
// body. This is the whole reason the route still exists.
if (!listing || listing.seller_id !== user.id) {
  return new Response(null, { status: 403 });
}

// Content-addressed key. The same photo uploaded twice lands on
// the same object, and the path can be cached forever because a
// different image is always a different key.
const key = "listings/" + listing.id + "/" + sha256(body.checksum) + ".jpg";

const { data, error } = await supabase.storage
  .from("listing-photos")
  .createSignedUploadUrl(key);

if (error) throw error;
return Response.json({ key, url: data.signedUrl, token: data.token });

The device then does the slow part on its own, with no function running and no timeout to race, and reports the key back when it is done.

lib/images/uploader.dart
final signed = await api.post(
  '/listings/' + listingId + '/uploads',
  body: {'checksum': sha256.convert(bytes).toString()},
);

await supabase.storage
    .from('listing-photos')
    .uploadToSignedUrl(signed.key, signed.token, bytes);

// The key, not a URL. URLs expire, get rewritten when the CDN
// changes and cannot be re-signed; a key is stable forever.
await api.post(
  '/listings/' + listingId + '/photos',
  body: {'key': signed.key, 'position': index},
);

One original, many derivatives, named by rule

Store exactly one file per photo: the resized original. Every other size is derived on request by a transformation service or an image CDN, cached at the edge, and never written to your database. A photos table with thumb_url, medium_url and large_url columns is three columns that can disagree with each other, three objects to delete, and a schema migration the day somebody wants a fourth size.

The derived URL is a function of the key and the size you want, so it can be computed anywhere — in the Flutter app, in the admin panel, in an email template — without a lookup. And because the key is content addressed, the derivative for a given URL never changes, which means you can serve it with a one-year immutable cache header and let the CDN answer nearly all of the traffic.

That cache header is not a detail. It is the difference between paying egress from your bucket on every scroll and paying it once per image per edge location.

The bill is egress, not storage

Take a marketplace with 5,000 live listings and four photos each. Twenty thousand objects. At roughly two cents per gigabyte-month, which is close to what most object stores charge:

Uploaded raw at 4 MB, that is 80 GB, or about $1.60 a month. Resized to 1600px at quality 82, call it 300 KB, that is 6 GB, or about 12 cents. The saving is real and it is also almost nothing. Storage is not where this goes wrong, and optimising it is why people conclude images are cheap.

Now serve the feed. Two thousand sessions a day, forty cards scrolled per session, one image per card. That is 80,000 image requests a day, about 2.4 million a month, and this is the number that decides your bill. Serve the 300 KB file straight into a 400-pixel card and that is 720 GB of egress a month — around $65 at nine cents a gigabyte. Serve a 25 KB derivative sized for the card and the same traffic is 60 GB, or about $5.40.

Same photos, same visitors, twelve times the bill, decided entirely by which file the feed asks for. And the version that costs twelve times more is also the version that scrolls badly, because those are the same bytes. Add the detail views on top — far fewer requests, at the full 1600px size — and they barely move the total.

Two levers are worth knowing before you pick a provider. Cache hit ratio multiplies straight through everything above, so an immutable cache header is worth more than any compression setting. And some object stores charge nothing for egress at all, which for a photo-heavy marketplace is the single largest architectural difference between two otherwise identical products.

Orphans are the bytes nobody deletes

Every upload flow has a gap. The seller uploads six photos, gets to the price field, and abandons the form. The objects exist. No row references them. Nothing in your application will ever mention them again, and they will be on the invoice every month for the life of the project.

A draft-heavy marketplace accumulates these faster than intuition suggests, because abandonment on a multi-step listing form is normal behaviour rather than an error. The fix is a reconcile job that treats the storage bucket as the untrusted side and the database as the truth.

supabase/migrations/0009_reconcile_photos.sql
create or replace function delete_orphan_photos()
returns integer
language sql
as $$
  with orphans as (
    delete from storage.objects o
     where o.bucket_id = 'listing-photos'
       -- Old enough that no upload could still be in progress.
       and o.created_at < now() - interval '24 hours'
       and not exists (
         select 1 from listing_photos p where p.key = o.name
       )
    returning 1
  )
  select count(*)::int from orphans;
$$;

select cron.schedule(
  'delete-orphan-photos',
  '17 3 * * *',
  $$ select delete_orphan_photos(); $$
);

The twenty-four hour grace period is the part not to skip. Without it the job races the upload flow and deletes photos out from under a seller who is still filling in the form, which is a far worse bug than the one it was written to fix.

Deleting a row does not delete the object

A foreign key with on delete cascade tidies your tables and does nothing whatsoever to your bucket. Delete a listing and the photo rows vanish; the files stay, now permanently unreferenced, which is the same orphan problem arriving through a different door — and this one scales with how successful the marketplace is, because every sold item is a deleted listing.

Deleting objects on the spot is one option and a delete that fails halfway leaves you inconsistent. The more durable pattern is to let the cascade write the intent — a row in a deletions table, or simply letting the reconcile job above find them — and have one scheduled job be the only thing in the system that removes bytes. One code path, retryable, auditable.

The same applies to moderation. A listing taken down for policy reasons needs its photos genuinely gone, not merely unlinked from a page, and needs it on a timescale you can state to whoever reported it.

Measure the feed, not the upload

Every instinct in this pipeline points at the upload, because that is the part with a progress bar and the part a seller complains about. The upload happens once per photo. The feed happens two and a half million times a month, and every decision made at upload time is multiplied by that number.

So the check that matters is not how fast a photo uploads. Open your own listing feed on a throttled connection with the network panel open, and look at the transferred size of one card. If it is not in the tens of kilobytes, the fix is a resize you have not done yet — and it is the same fix for the scroll performance, the mobile data your users spend, and the bill.

ClassiMarket — $18

A Flutter classifieds marketplace where every listing is a photo feed — infinite scroll, category hierarchy, offers and a Next.js admin with listing approval, full source included.

See the live demo