Uploading Files
Multipart upload from a local file URI, real progress from an upload task, and why a large upload needs a background session.
FileSystem.createUploadTask(url, fileUri, options, (progress) => void): UploadTaskAn upload sends bytes that are already on disk — a photo the user picked, a recording, a document. You do not read the file into JavaScript first. You hand the native layer a file:// URI and it streams the bytes; reading a 40 MB photo into memory as base64 is how upload screens crash on older phones.
Multipart with fetch
The portable form is FormData. React Native accepts a file as { uri, name, type } — that is not a web File, it is an object the native networking layer recognises, and under strict TypeScript it needs a cast. Since Expo SDK 54 there is a typed alternative: fetch from expo/fetch accepts a File from expo-file-system directly, appended to FormData with no cast. From SDK 56 on, expo/fetch is installed as the global fetch, so the import is only there to make the source obvious.
Progress and background
Neither fetch reports progress, so a percentage needs an upload task. The modern expo-file-system API has had one since SDK 56: file.createUploadTask(url, options), whose onProgress callback receives bytesSent and totalBytes. The older equivalent, used in the examples below and still what most code in the wild calls, is createUploadTask from expo-file-system/legacy: it takes a URL, a file URI, options and a progress callback that receives totalBytesSent and totalBytesExpectedToSend. It also uses a background session by default, so a transfer can survive the user switching away — sessionType is an iOS-only option.
What trips people up
An iOS photo may arrive as HEIC with a matching mimeType, which many servers reject; convert it, or accept it server-side. And an upload cannot be resumed: a dropped connection means starting again, so large files want a chunking endpoint rather than a longer timeout.
Example
import * as FileSystem from 'expo-file-system/legacy';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
// The URI comes from a picker or the camera; this screen only uploads it.
type UploadButtonProps = { uri: string; mimeType?: string };
export default function UploadButton({ uri, mimeType = 'image/jpeg' }: UploadButtonProps) {
const [percent, setPercent] = useState<number | null>(null);
async function upload() {
setPercent(0);
const task = FileSystem.createUploadTask(
'https://api.example.com/photos',
uri, // a file:// URI, never the bytes themselves
{
httpMethod: 'POST',
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
fieldName: 'photo', // the form field the server reads
mimeType,
parameters: { caption: 'from my phone' }, // extra text fields in the same body
headers: { Accept: 'application/json' }, // no Content-Type: the module sets it
},
({ totalBytesSent, totalBytesExpectedToSend }) => {
setPercent(Math.round((totalBytesSent / totalBytesExpectedToSend) * 100));
}
);
try {
const result = await task.uploadAsync(); // undefined if the task was cancelled
if (!result || result.status >= 400) throw new Error(`Upload failed: ${result?.status}`);
} finally {
setPercent(null);
}
}
return (
<View style={styles.row}>
<Pressable onPress={upload} disabled={percent !== null}>
<Text>{percent === null ? 'Upload photo' : `Uploading ${percent}%`}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({ row: { padding: 16 } });When to use it
- Send a user's chosen profile photo to your API with a progress bar.
- Attach a recorded voice note to a support ticket alongside text fields.
- Keep an upload running while the user switches to another app.
- Cancel a half-finished upload when the user backs out of the screen.
More examples
The same upload with plain fetch, two ways
Both send an identical multipart request. The second is the one to write in a new SDK 57 project; the first is the one you will meet in older codebases and in most answers online, so it is worth recognising. Neither reports progress, which is the whole reason the upload task exists.
// 1. The long-standing React Native form. The file is described, not read.
export async function uploadWithFormData(uri: string, mimeType = 'image/jpeg') {
const form = new FormData();
// Not a web File: this shape is understood by the native layer, hence the cast.
form.append('photo', { uri, name: 'photo.jpg', type: mimeType } as unknown as Blob);
form.append('caption', 'from my phone');
const response = await fetch('https://api.example.com/photos', {
method: 'POST',
body: form, // and no Content-Type header by hand
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as { id: string };
}
// 2. The typed modern Expo equivalent, available since SDK 54: a real File object, no cast.
import { fetch as expoFetch } from 'expo/fetch';
import { File } from 'expo-file-system';
export async function uploadWithExpoFetch(uri: string) {
const form = new FormData();
form.append('photo', new File(uri));
form.append('caption', 'from my phone');
const response = await expoFetch('https://api.example.com/photos', {
method: 'POST',
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as { id: string };
}Cancelling an upload the user walked away from
On screen this is a percentage that climbs as soon as the component mounts and stops the moment the user goes back. The cancellation belongs in the effect cleanup because that is the only code guaranteed to run on unmount; an upload left running on a screen nobody can see is a bug users notice on their data bill.
import * as FileSystem from 'expo-file-system/legacy';
import { useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
export default function AutoUpload({ uri }: { uri: string }) {
const [percent, setPercent] = useState(0);
// A ref, not state: storing the task must not cause a re-render.
const taskRef = useRef<FileSystem.UploadTask | null>(null);
useEffect(() => {
const task = FileSystem.createUploadTask(
'https://api.example.com/photos',
uri,
{ uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'photo' },
({ totalBytesSent, totalBytesExpectedToSend }) =>
setPercent(Math.round((totalBytesSent / totalBytesExpectedToSend) * 100))
);
taskRef.current = task;
task.uploadAsync().catch(() => setPercent(0));
// Leaving the screen stops the transfer instead of spending the user's data.
return () => {
taskRef.current?.cancelAsync();
taskRef.current = null;
};
}, [uri]);
return (
<View style={styles.row}>
<Text>{percent}%</Text>
</View>
);
}
const styles = StyleSheet.create({ row: { padding: 16 } });Turning a picked asset into upload arguments
Shown because the two most common upload bugs start here: fileName and mimeType are optional fields, and code that assumes them present sends "undefined" as the filename. Lowering quality is the cheapest fix for slow uploads, since an original phone photo is often ten times larger than anything the server will display.
import * as ImagePicker from 'expo-image-picker';
export type UploadTarget = { uri: string; name: string; mimeType: string };
// The picker itself is covered later; this is only the shape of its result.
export async function pickForUpload(): Promise<UploadTarget | null> {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.7, // a smaller file means a far shorter upload
});
if (result.canceled) return null;
const asset = result.assets[0];
return {
uri: asset.uri,
// fileName and mimeType are optional on the asset, so both need a fallback.
name: asset.fileName ?? 'upload.jpg',
mimeType: asset.mimeType ?? 'image/jpeg',
};
}
Discussion