Android image uploads in Expo kept coming through empty
My image upload code worked on iOS and quietly produced zero-byte files on Android. Here is why fetch() against a file:// URI is the wrong way to read those bytes, and the one-line picker option that fixed it.
Why did the same image upload work on iOS and produce empty files on Android? That is the question I stared at for an afternoon. Even Steven, my expense-splitting Expo app, lets people upload a profile photo. I wrote the upload once, tested it on the iOS simulator, watched the avatar appear, and moved on. Two weeks later I ran it on a real Android phone. The upload returned 200. The bucket showed a file with the right name and a content type of image/jpeg. The file was zero bytes. Every Android upload was zero bytes.
The short answer: I was reading the image bytes with fetch(uri).blob(), which is the pattern every web tutorial reaches for, and it is the wrong tool on React Native. The fix was to let the image picker hand me the bytes directly. The interesting part is everything in between: why it looks correct, why it fails silently, and why iOS hid the bug for two weeks.
What does fetch().blob() actually do here?
The chain I wrote looked harmless. You pick an image with expo-image-picker, get back a file:// URI, call fetch(uri) to read the bytes, then call .blob() to turn the response into something Supabase Storage can upload. Every piece of that is a documented Web platform interface, so it reads like correct code.
Here is the analogy that made it click for me. The file:// handler inside Expo's fetch is a translator. On iOS that translator speaks the language fluently: it reads the file off disk and hands back a real Blob full of bytes. On Android the same translator nods along but never actually fetches the bytes. It builds a Blob whose size property looks right, but when Supabase's client tries to read the contents to ship them, there is nothing there.
A concrete example of how convincing this is. I added console.log(blob.size) right after the .blob() call, expecting zero. It printed the real byte count of the image, somewhere around 300 KB. The Blob said it had bytes. The bucket said it did not. Under the hood, Supabase's client wraps the Blob in FormData and posts a multipart body, and React Native's FormData on Android does not know how to pull real bytes out of that lazy Blob. It sends the file part empty. The server returns 200 because the request was valid. The bytes were never there.
The reason iOS hid it: I tested on the iOS simulator first because that was the device I had open. The iOS branch of the shim is more complete than the Android one, so on iOS the upload genuinely worked. If I had run Android first, I would have caught it in five minutes. Instead the bug slept for two weeks.
So what actually fixed it?
The picker already has the bytes. It read them off disk to make the URI in the first place. expo-image-picker has had a base64: true option for years. Set it on the launch call and the asset comes back with a .base64 field already filled in by the native module. Then I decode that string into an ArrayBuffer and hand the buffer straight to Supabase. No fetch, no Blob, no FormData edge case. The repo function stops reading from disk at all and just transforms bytes into an upload.
import { decode } from 'base64-arraybuffer';
export async function uploadProfilePhoto(
client: SupabaseClient<Database>,
userId: string,
imageUri: string,
base64Data: string,
mimeType?: string,
): Promise<string> {
const arrayBuffer = decode(base64Data);
const ext = imageUri.split('.').pop()?.toLowerCase() ?? 'jpg';
const contentType = mimeType ?? (ext === 'png' ? 'image/png' : 'image/jpeg');
const filePath = `avatars/${userId}.${ext}`;
const { error: uploadError } = await client.storage
.from('profile-photos')
.upload(filePath, arrayBuffer, { contentType, upsert: true });
if (uploadError) throw uploadError;
const { data } = client.storage.from('profile-photos').getPublicUrl(filePath);
return `${data.publicUrl}?t=${Date.now()}`;
}On the screen, the change is two lines: add base64: true to the picker options, then pass asset.base64 and asset.mimeType through to the function above. The picker is the source of truth for the mime type, so I prefer it when present and fall back to the file extension only when it is missing. On Android the avatar appeared. On iOS it still appeared. Real bytes in the bucket, both platforms.
Things that surprised me
A few of these I had to learn the hard way.
- The upload threw no error. uploadError was null, the SDK reported success, the dashboard reported success. I went looking for a 4xx or a thrown exception and there was nothing to find. I only saw the truth by opening the bucket and clicking the file.
- My first fix used expo-file-system to read the file as base64. It worked on Android, then broke against the in-app camera flow, and its readAsStringAsync had moved under expo-file-system/legacy on my SDK. I had picked up a new dependency to do a job the picker already did.
- On one Android device, the asset came back with a base64 field that was an empty string, not null. So I guard for !asset.base64 and tell the user to retry rather than upload nothing that looks like something.
- base64 inflates the payload by about a third in memory. Fine for a 1 MB avatar behind a size cap. For a 30 MB video I would reach for a streamed upload instead, but I would still skip fetch().blob().
- The new function is testable for free. Because it takes a base64 string in, a Jest test passes a fixed 1x1 PNG and asserts an ArrayBuffer was uploaded, no filesystem mock needed. The old fetch version could not be tested without mocking the platform.
When is this worth caring about?
The lesson I want to keep is narrow. It is not 'never use Web APIs in React Native'. fetch against an HTTPS endpoint is the right call, and FormData for multipart bodies is the right call. The shims that work, work.
The thing I actually take away: when an Expo native module already owns the data you need, that module is where you should read it. expo-image-picker owns the bytes of the picked image. Asking it for those bytes is one trip across the native boundary. Asking it for a URI and then opening the URI is two trips, with the second one going through whatever shim happens to handle that URI shape on the current platform. So now I treat a URI from a native module as an opaque token: it is for display or for handing to another module, not for reading.
If you are uploading images from Expo to Supabase Storage in 2026, I would write it the right way the first time. Set base64: true on the picker, decode to an ArrayBuffer, and hand that to .upload(). And whatever you do, test on a real Android device before you call it done. iOS will lie to you here.


