One question decides the directory
Can you get this file back without the user noticing? If yes, it belongs in the cache. If no, it belongs in documents. That is the entire decision, and every rule below follows from it.
| Location | Survives | In iOS backup | Put here |
|---|---|---|---|
documentDirectory | Until you delete it | Yes, on iOS | Recordings, drafts, exports — anything user-authored |
cacheDirectory | Until the OS wants the space | No | Downloaded media, thumbnails, anything re-fetchable |
| bundleDirectory | Read-only, ships with the app | n/a | Assets you shipped, never for writes |
| Shared / external storage | Outside your app container | n/a | Handing a file to the user — use the share sheet instead |
The backup column is the part teams miss. Files in the document directory go into the user’s iCloud backup. Apple is explicit that apps should not back up data they could re-download, and an app that fills someone’s iCloud quota with cached video is a genuine review risk — not a hypothetical one. Putting regenerable files in the cache directory is the mechanism for excluding them; there is no separate flag you were supposed to set.
And the other direction is worse. Cache purging happens while your app is closed, silently, on devices that are low on space — which describes almost nobody’s development phone and a large fraction of real users. A voice memo written to the cache directory works flawlessly for months of testing and then generates a stream of “my recordings are gone” tickets that you cannot reproduce.
The absolute-path trap
This is the one that produces “every image in the app broke after the update.” On iOS the app container path contains a generated identifier, and that identifier is not guaranteed to survive an update or a restore. So:
// WRONG — this string is only valid for this install
db.insert({ photo: 'file:///var/mobile/.../Documents/photos/a1.jpg' });
// RIGHT — store the relative part, rebuild at read time
db.insert({ photo: 'photos/a1.jpg' });
const uri = documentDirectory + row.photo; // resolved fresh every timeThe files are never actually lost when this happens, which is what makes it so confusing to debug — a directory listing shows everything present while every stored path points at a container that no longer exists. If you already shipped absolute paths, the migration is straightforward: take everything after the last known directory segment and re-prefix it on read. Do it before the install base grows.
Reading and writing, with the failure cases
import * as FileSystem from 'expo-file-system';
const DIR = FileSystem.documentDirectory + 'photos/';
// 1. the folder must exist before you write into it
async function ensureDir() {
const info = await FileSystem.getInfoAsync(DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(DIR, { intermediates: true });
}
}
// 2. write
await ensureDir();
await FileSystem.writeAsStringAsync(DIR + 'note.txt', 'hello');
// 3. read — always check first, the file may be gone
const info = await FileSystem.getInfoAsync(DIR + 'note.txt');
if (!info.exists) return refetch();
const text = await FileSystem.readAsStringAsync(DIR + 'note.txt');Three habits worth forming:
- Create the directory, every time. Writing into a folder that does not exist fails rather than creating it. A fresh install has no
photos/folder, so the code path that works on your phone throws on a new one — a classic first-launch-only crash. getInfoAsyncbefore every read. Not defensive paranoia — the cache genuinely disappears. Branch to a re-fetch instead of letting a read throw into an error boundary.- Watch the encoding.Text is the default; binary needs base64, and base64 in JavaScript memory is roughly a third larger than the file. Reading a 40 MB video as base64 to “check something” is how you get an out-of-memory crash on a mid-range Android device.
Downloads with progress and resume
A one-shot download gives you a promise and nothing else — no progress, and an interruption means starting over. For anything large enough to want a progress bar, create it resumable:
const target = FileSystem.cacheDirectory + 'episode-12.mp3';
const task = FileSystem.createDownloadResumable(
'https://cdn.example.com/episode-12.mp3',
target,
{},
({ totalBytesWritten, totalBytesExpectedToWrite }) => {
// -1 expected means the server sent no Content-Length
if (totalBytesExpectedToWrite > 0) {
setProgress(totalBytesWritten / totalBytesExpectedToWrite);
}
},
);
const result = await task.downloadAsync(); // undefined if paused
// to survive a backgrounded app:
const snapshot = task.savable(); // persist this, resume laterTwo details that matter in production. The expected-bytes value is negative when the server does not send a Content-Length header, so dividing by it produces a progress bar that runs backwards — guard it. And the download does not survive the app being killed on its own: persist the savable snapshot if you want to continue afterwards, otherwise the user gets a fresh start and a second charge against their data plan.
Files, or a database?
A surprising amount of “filesystem” code is a database wearing a costume. If you find yourself writing JSON to a file, reading it all back, mutating an array, and writing it again, you have built a very slow database with no atomicity — two writes racing will silently lose one, and a crash mid-write leaves a truncated file that fails to parse on next launch.
- Small key-value state (settings, flags, last-opened tab) → AsyncStorage or MMKV.
- Anything you query, sort, or paginate → SQLite, which gives you transactions and does not rewrite the world on every change.
- Credentials → SecureStore, never a file.
- Actual blobs — media, PDFs, exports, model files → the filesystem, with a row in SQLite holding the relative path.
That last pattern is the one to reach for by default: metadata in the database, bytes on disk, relative paths joining the two. It survives updates, it makes cache eviction recoverable, and it keeps your queries fast because you are never parsing a 12 MB JSON file to render a list.
Get the storage layer generated for you
Directory choice, folder creation, relative paths, existence checks before reads — none of it is hard, all of it is easy to get subtly wrong once and then live with for a year. If you want a starting point where offline storage is already structured this way, describe your app at shipnative.dev and you get a working React Native project with local data wired up — running on your phone in minutes, and yours to edit as a normal Expo codebase. The offline-first guide covers how the pieces fit together.