Back to Tutorials

Firebase Storage Uploads in Expo React Native

Picking an image is the easy part. What breaks in production is everything after it: 12-megapixel photos crashing Android, uploads that report no progress, download URLs quietly handing out permanent public access, and Storage rules that reject every file because nobody set a content type. This is the whole path, with the current Expo APIs.

September 13, 2026 12 min read Paddy B
ExpoReact NativeFirebase StorageImage UploadFirestore

The short version

Pick with expo-image-picker, resize before uploading, read the file as bytes rather than base64, and send it with uploadBytesResumable so you get progress and cancellation. Always pass contentType explicitly. Store the storage path in Firestore, never the download URL. Then scope Storage rules to the signed-in uid and enforce size and type there, because the client can lie about both.

This assumes Firebase Auth is already wired up — every rule below is keyed on request.auth.uid. If you are not there yet, start with Firebase Auth in Expo React Native.

1. Pick the image

npx expo install expo-image-picker expo-image-manipulator expo-file-system

The mediaTypes option now takes a string or an array of strings. The old MediaTypeOptions enum still works but is deprecated, and it is the single most common reason a copy-pasted snippet from an older guide throws:

import * as ImagePicker from "expo-image-picker"; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], // not ImagePicker.MediaTypeOptions.Images allowsMultipleSelection: false, quality: 1, // full quality here; we resize in step 2 }); if (result.canceled) return; const asset = result.assets[0]; // { uri, width, height, fileSize, mimeType, fileName }

Leave quality at 1 and do the compression yourself in the next step. The picker's quality setting re-encodes without changing dimensions, so you still pay for a 4000px-wide image — just a blurrier one.

2. Resize before you upload, not after

This is the highest-value line of code in the whole flow. A modern phone camera produces images in the 4–12 MB range, and almost no app displays them above about 1600px. Resizing first cuts upload time, storage cost, and download egress in one move — and egress is the bill that actually grows.

manipulateAsync has been replaced by a contextual, object-oriented API:

import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; const context = ImageManipulator.manipulate(asset.uri); context.resize({ width: 1600 }); // height follows the aspect ratio const rendered = await context.renderAsync(); const resized = await rendered.saveAsync({ format: SaveFormat.JPEG, compress: 0.8, }); // resized.uri is the file you actually upload

Inside a component, useImageManipulator(uri) gives you the same context tied to the component lifecycle. Keep the original around only if your product genuinely needs full resolution — and if it does, upload the original and a thumbnail as two separate objects rather than fetching a 12 MB file every time a list renders.

3. Turn the file URI into bytes

The URI from the picker is a pointer to a cached file, not the image data, so it has to be read before it can be uploaded. uploadBytesResumable accepts a Blob, a Uint8Array, or an ArrayBuffer. The object-based file API introduced in SDK 54 gets you there directly:

import { File } from "expo-file-system"; const file = new File(resized.uri); const bytes = await file.bytes(); // Uint8Array

If you are on an older SDK, or you see the legacy functions throwing deprecation errors after upgrading, the previous API still exists at expo-file-system/legacy while you migrate. The long-standing await (await fetch(uri)).blob() trick also still works and is what most older tutorials show.

Do not read the image as base64

Base64 inflates the payload by roughly a third and has to sit in JavaScript memory as one contiguous string. That is the actual cause of nearly every "large uploads crash on Android with no error" report. Bytes or a blob, never a base64 string.

4. Upload with progress and cancellation

Use uploadBytesResumable, not uploadBytes. It reports bytes transferred as they go, which uploadBytes cannot, and it resumes rather than restarting when a large upload is interrupted:

import { getStorage, ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; const storagePath = `users/${uid}/uploads/${Date.now()}.jpg`; const task = uploadBytesResumable(ref(getStorage(), storagePath), bytes, { contentType: "image/jpeg", // required: see the warning below customMetadata: { uploadedBy: uid }, }); task.on( "state_changed", (snap) => setProgress(snap.bytesTransferred / snap.totalBytes), (error) => { if (error.code === "storage/canceled") return; // user cancelled, not a failure reportError(error); }, () => saveMetadata(storagePath), );

The task handle gives you task.pause(), task.resume(), and task.cancel(). Wire cancel to a button rather than to component unmount — a user navigating away from the screen usually wants the upload to keep going, and cancelling in a cleanup function silently kills every upload the moment the screen is popped.

Always set contentType

When you pass a Blob, Firebase infers the type from blob.type. When you pass a Uint8Array there is nothing to infer from, so the object is stored as application/octet-stream. Any rule matching image/.* then rejects the upload, and the error you get back is a generic storage/unauthorized that points nowhere near the real cause.

5. Store the path in Firestore, not the download URL

Storage holds bytes. Everything you want to query — who uploaded it, when, what it belongs to — belongs in Firestore:

await addDoc(collection(db, "users", uid, "photos"), { storagePath, // "users/abc123/uploads/1757....jpg" contentType: "image/jpeg", sizeBytes: bytes.byteLength, width: resized.width, height: resized.height, createdAt: serverTimestamp(), });

Saving the result of getDownloadURL instead is the mistake worth avoiding. That URL carries a long-lived access token that works for anyone who has the link, regardless of your Storage rules — and if you ever revoke the token, every copy already saved in Firestore breaks at once. Store the path, call getDownloadURL(ref(getStorage(), storagePath)) when you actually need to render, and access stays subject to your rules.

One thing Firebase will not do for you: deleting the Firestore document does not delete the object. Orphaned files accumulate silently and keep billing. Clean them up with a Cloud Function on document delete — the same pattern as the token cleanup in Firebase Functions for Expo push notifications.

6. Storage rules, scoped to the uid

Storage rules live in their own file with their own syntax, and they are deployed separately from Firestore rules. Editing storage.rules and running a Firestore-only deploy is a genuinely common way to lose an afternoon:

rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /users/{uid}/uploads/{fileName} { allow read: if request.auth != null && request.auth.uid == uid; allow write: if request.auth != null && request.auth.uid == uid && request.resource.size < 5 * 1024 * 1024 && request.resource.contentType.matches('image/.*'); } } }
firebase deploy --only storage

In a write rule request.resource is the incoming file, so request.resource.size caps the upload rather than describing what is already there. Mirror that limit client-side too — not for security, but so the user gets "that photo is too large" instead of a failed upload with a cryptic error code.

What rules cannot do is verify that the bytes are really an image. contentType is metadata the client declares, and a determined client can declare anything. If it matters, inspect the file server-side in a Storage onFinalize function and delete what fails. The same ownership thinking applies as in Firestore rules and offline data, and App Check adds a layer against clients that are not your app at all.

7. What to test before shipping

  1. A real camera photo, not a simulator screenshot. Screenshots are small and hide every memory problem. Use a 12-megapixel original.
  2. Airplane mode mid-upload. Progress should stall and the error handler should fire with something you can retry, not a hang.
  3. Background the app mid-upload. Uploads do not reliably continue while suspended; decide whether you retry on return or surface a failure.
  4. Cancel, then upload again. Confirm the storage/canceled branch does not report an error and that the second attempt works.
  5. A signed-in user writing to another user's path. Should fail. If it does not, your rule path is wrong.
  6. An oversized file. Should fail at the rule even with the client-side check bypassed.

The rules cases are far quicker against the Firebase emulator than a live bucket — the emulator setup in Firestore rules and offline data covers getting one running.

8. Production checklist

  • Images resized and compressed before upload; no full-resolution originals unless the product needs them.
  • contentType set explicitly on every upload.
  • Storage paths include the uid, and rules match that path exactly.
  • Size and content type enforced in rules, mirrored client-side for a better message.
  • storage.rules actually deployed, not just edited.
  • Firestore stores the path; download URLs are fetched on demand.
  • A cleanup function removes objects when their document is deleted.
  • Budget alerts set — download egress, not storage, is what grows.

Related tutorials

Need this built for you? I take on contract Expo and React Native work — see how I can help.

Support tutorials