Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 8 min read

Expo FileSystem: Which Directory, and What Gets Deleted

Writing a file in an Expo app is one line. Deciding whereto write it is the whole problem, and getting it wrong produces two of the most confusing bug reports in mobile: files that vanish overnight on other people’s phones but never on yours, and images that all break at once after an app update while the files themselves are perfectly intact. Neither is a bug in the library. Both come from the same two facts — the OS deletes your cache whenever it likes, and your app’s absolute path is not permanent. Here is how the directories actually differ, how to download with progress and resume, and the handful of rules that keep files where you left them.

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.

LocationSurvivesIn iOS backupPut here
documentDirectoryUntil you delete itYes, on iOSRecordings, drafts, exports — anything user-authored
cacheDirectoryUntil the OS wants the spaceNoDownloaded media, thumbnails, anything re-fetchable
bundleDirectoryRead-only, ships with the appn/aAssets you shipped, never for writes
Shared / external storageOutside your app containern/aHanding 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 time

The 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:

  1. 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.
  2. getInfoAsync before every read. Not defensive paranoia — the cache genuinely disappears. Branch to a re-fetch instead of letting a read throw into an error boundary.
  3. 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 later

Two 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.

Frequently Asked Questions

What is the difference between documentDirectory and cacheDirectory in Expo?

The document directory is for files your app cannot regenerate — user recordings, drafts, anything the person would consider theirs. It persists until you delete it and is included in device backups on iOS. The cache directory is for files you can fetch again; the OS is free to delete it whenever storage runs low, including while your app is closed. Choosing cache for user data means overnight data loss; choosing documents for a video cache means an app that Apple flags for excessive backup size.

Why do my downloaded files disappear from an Expo app?

Almost always because they are in the cache directory, which iOS and Android purge under storage pressure without asking or notifying you. It looks random because it depends on the device filling up, so it never reproduces on your own phone and shows up constantly in support tickets. Move anything the user would miss to the document directory, and treat every cache read as possibly missing rather than assuming a file you wrote is still there.

Can I store an absolute file path in my database?

No — on iOS the container path includes a UUID that can change between installs and updates, so a path stored today can point nowhere tomorrow while the file itself is fine. Store the filename or a path relative to the directory constant, and rebuild the absolute path at read time by joining it to the current documentDirectory value. This is the cause of the classic "all my images broke after the update" bug.

How do I download a file with a progress bar in Expo?

Use a resumable download rather than a one-shot call. Creating it with a callback gives you total bytes written and total bytes expected on every chunk, which is what a progress bar needs, and it hands back a savable state so an interrupted download can continue instead of restarting. A plain download call gives you no progress and no resume.

Does Expo FileSystem work on web?

Only partially, and not in the way native code expects. There is no persistent app-container filesystem in a browser, so directory constants and most path operations do not translate. For a universal app, put file access behind your own module with a web branch that uses blobs, object URLs, or IndexedDB, so the rest of your code does not have to know which platform it is on.

How do I stop iOS from backing up my cached files to iCloud?

Put them in the cache directory rather than the document directory — that is the supported way to signal a file is regenerable, and it keeps them out of backups. Apple explicitly expects apps not to back up data that can be re-downloaded, and an app whose backup footprint is full of cached media is a review risk. Directory choice is the mechanism, not a separate flag.

→

Expo SQLite: Local Data That Survives

When a file is the wrong shape and you want real queries instead.

Read guide →
→

React Native Image Upload

The other direction — picking, compressing, and posting files to a server.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.