Supabase Storage uploads that survive real mobile networks in Expo apps
Uploads are the feature that works perfectly on office wifi and falls apart everywhere else. A user takes a photo in a parking garage, the tunnel kills the connection halfway through, and your app either retries gracefully or leaves them staring at a spinner. If you ship an Expo app backed by Supabase Storage, the difference between those two outcomes is a handful of decisions you make once: private buckets, signed URLs, tight RLS policies, client-side compression, and retries that assume failure.
This guide walks through a production-ready upload setup for Expo and React Native apps. It assumes you already have Supabase Auth wired up and a bucket created. If your row-level security fundamentals are shaky, read our /blog/supabase-rls-production-checklist first, because everything below builds on policies gating storage.objects correctly.
Use private buckets and signed URLs by default
Make every user-content bucket private unless you have a specific reason not to. Public buckets are convenient for avatars and marketing assets, but user uploads, identity documents, receipts, and anything tied to an account should live behind signed URLs that expire.
The pattern is simple: the file stays private at rest, and your app mints a short-lived URL whenever it needs to display it. Reads go through your RLS policies, and the URL dies after a few minutes so a leaked link has a short blast radius.
// Mint a short-lived URL for displaying a private file
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUrl(`avatars/${userId}.jpg`, 300); // 5 minutes
if (error) throw error;
const displayUrl = data.signedUrl;Keep expiry times short for sensitive content and longer for low-risk thumbnails. A common split is five minutes for documents and one hour for profile pictures. Cache the URL in memory while the screen is visible and re-mint on focus rather than storing signed URLs in your database, since they expire and go stale.
One more habit worth building early: never trust a client-provided path blindly. Scope every object key with the authenticated user's id, such as userId/filename, so a malicious client cannot overwrite another user's file even if a policy has a gap.
Write RLS policies for storage.objects once, carefully
Storage access control in Supabase lives in Postgres policies on the storage.objects table, keyed off the bucket id and the object name. This is the layer that actually protects your files, so write it deliberately and test it with a second account before shipping.
A solid baseline for a private per-user bucket is three policies: users can insert into their own folder, read their own folder, and delete their own folder. Nothing else.
-- Users can upload only into their own folder
create policy "Users can upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can read only their own files
create policy "Users can read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can delete only their own files
create policy "Users can delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);Notice there is no update policy here. Uploads in this design are immutable: to replace an avatar, the client uploads a new object and updates the profile row to point at it. That eliminates a whole class of overwrite bugs and makes cleanup straightforward.
If your app needs sharing, such as a coach viewing a client's progress photos, add a narrow select policy gated on a relationship table rather than opening the bucket. Shared access should always be derived from application data, never from guessable paths. Session handling matters here too, since an expired session means policy checks fail in confusing ways; our notes in /blog/supabase-auth-expo-session-production cover keeping Expo sessions alive so storage calls do not break at the worst moment.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Compress images before they leave the phone
Phone cameras produce enormous files. A single photo can be 8 to 12 MB, and uploading that over a cellular connection is slow, expensive for the user, and wasteful for your storage bill. Compress on the device before upload, every time, with no exceptions.
Pick target dimensions per use case rather than using one size everywhere. Avatars rarely need more than 512 pixels on the long edge. In-app photo displays are fine at 1280 to 1600 pixels. Keep the original only when your product genuinely needs it, such as a printing or archival feature, and store it in a separate bucket so lifecycle rules can differ.
import * as ImageManipulator from 'expo-image-manipulator';
async function prepareImage(uri: string) {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1280 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);
return result.uri;
}JPEG at 0.7 quality and a bounded width cuts most photos to a few hundred kilobytes with no visible difference on a phone screen. For images with transparency or screenshots with text, prefer PNG. Strip EXIF location data during this step as well, since photos taken in the field often carry GPS coordinates you do not want to store or serve.
Validate on the server side too. Add a check constraint or a database trigger that rejects objects above your size ceiling, so a client that skips compression cannot blow up your bucket. Defense in depth beats trusting any single client build.
Retry uploads like the network is hostile
Mobile networks drop, stall, and switch between wifi and cellular mid-request. Your upload code should treat failure as the normal case. That means resumable uploads for large files, bounded retries with backoff for everything else, and UI states that tell the user what is actually happening.
For files above a few megabytes, use chunked or resumable uploads so an interrupted transfer picks up where it stopped instead of starting over. For smaller images, a simple retry loop with exponential backoff and a cap is enough. Show distinct states for queued, uploading with progress, paused, and failed with a manual retry button. A spinner with no progress bar is how uploads go to die.
async function uploadWithRetry(
bucket: string,
path: string,
file: Blob,
maxAttempts = 4
) {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const { error } = await supabase.storage
.from(bucket)
.upload(path, file, { upsert: false });
if (!error) return;
lastError = error;
const delay = Math.min(1000 * 2 ** attempt, 8000);
await new Promise((r) => setTimeout(r, delay));
}
throw lastError;
}A few details make this solid in practice. Use upsert: false so retries never silently overwrite a completed upload. Generate the object key once before the first attempt and reuse it across retries so you do not create duplicates. Listen for connectivity changes and pause the queue when offline rather than burning through retry attempts. And always surface the final failure with a retry affordance instead of swallowing the error.
Clean up orphans before storage bills you
Every app that uploads files accumulates orphans: images uploaded but never attached to a record, avatars replaced by newer ones, files whose parent row was deleted. Without a cleanup strategy, storage grows forever and your bill with it.
The cheapest fix is a database-driven approach. Store a reference to the object key in the owning table, such as a profile.avatar_path column, and delete the storage object whenever the reference changes or the row is deleted. A Postgres trigger or an application-level transaction keeps the two in sync.
-- Find candidate orphans: objects with no referencing profile
select o.name, o.created_at
from storage.objects o
left join public.profiles p
on p.avatar_path = o.name
where o.bucket_id = 'user-uploads'
and o.created_at < now() - interval '7 days'
and p.id is null
order by o.created_at;Run a scheduled job weekly that lists files older than a grace period with no referencing row and removes them. The grace period matters: a file uploaded minutes ago may simply not be attached yet because the user has not finished the form. Seven days is a safe default. Log every deletion so you can audit what the job removed, and consider soft-deleting the database reference first so support can recover from mistakes.
Put it together in an upload helper
When these pieces combine, your upload flow looks like this: authenticate, compress on device, generate a user-scoped key, upload with retries, write the key to the owning table, and serve reads through short-lived signed URLs. Cleanup runs on a schedule in the background. Each step is small on its own, but together they produce uploads that survive parking garages, tunnels, and budget Android phones on 3G.
Start with the policies and the private bucket, since those are hardest to retrofit after launch. Add compression next for the biggest user-visible win. Then retries, then cleanup automation. Ship in that order and your storage layer will be boring in the best possible way.
Sources
- Supabase Storage documentation — buckets, access control, signed URLs, and resumable uploads.
- Related reading on this blog: /blog/supabase-rls-production-checklist for policy fundamentals and /blog/supabase-auth-expo-session-production for keeping sessions alive during storage calls.
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included