I shipped a "Backup your data" button for an offline mood tracker. It exported every entry to a JSON file. Users could save it, move phones, import it, done.
Then I changed the app's Android package id. A new install is a new sandbox. I restored a backup on the fresh install and every photo was gone. The mood entries came back fine. The images attached to them rendered as broken thumbnails.
The backup was not actually a backup. It was a list of pointers to files that no longer existed.
Why path references quietly fail
The app stores photos on disk and keeps a row in SQLite that points at the file:
SELECT entry_id, file_path, media_type FROM entry_media;
file_path is an absolute path like file:///data/user/0/com.example.app/files/entry_media/1717800000000_a1b2c3.jpg. My v1 export just dumped those rows into JSON. On the SAME install, re-importing works, because the files are still there. The bug hides until the one moment a backup matters: a different device, a reinstall, a restored phone.
On a new install none of those paths resolve. The import inserts rows pointing at nothing. The user sees broken images and assumes the app ate their memories.
If your "export" only carries file paths, you don't have a portable backup. You have a backup that works on exactly the machine that does not need one.
The fix: carry the bytes, not the path
A backup is portable only if it contains the actual data. So the export now reads each photo off disk and embeds it as base64 directly in the JSON.
// expo-file-system moved the classic function API to /legacy at SDK 54.
// readAsStringAsync, writeAsStringAsync and EncodingType live there.
import * as FileSystem from 'expo-file-system/legacy';
async function readPhotoBase64(filePath: string): Promise<string | null> {
try {
const info = await FileSystem.getInfoAsync(filePath);
if (!info.exists) {
console.warn(`Skipping photo export, source missing: ${filePath}`);
return null;
}
return await FileSystem.readAsStringAsync(filePath, {
encoding: FileSystem.EncodingType.Base64,
});
} catch (error) {
console.warn(`Skipping photo export, unreadable: ${filePath}`);
return null;
}
}
One detail that bit me first: on Expo SDK 54 and up, readAsStringAsync and EncodingType are not on the default expo-file-system import anymore. They live at the expo-file-system/legacy entrypoint (Expo's own docs confirm it, and there is an open issue for people who hit the missing methods). If your base64 read returns undefined, check that import before anything else.
Each photo becomes a small object with the bytes inside it:
interface ExportPhoto {
media_type: string;
file_path: string; // original path, informational only
ext: string; // used to name the restored file
data_base64: string; // the actual image bytes
}
I bumped the export to version: 3 so the import side can tell embedded backups from the old path-only ones.
Restore the files before you touch the database
The import is where the ordering matters. You have to materialize the photos to the new install's media directory FIRST, then run the database transaction that points rows at the new paths.
export async function writeBase64ToMediaDir(base64: string, ext: string): Promise<string> {
await ensureMediaDir();
const dest = `${MEDIA_DIR}${Date.now()}_${rand()}.${ext}`;
await FileSystem.writeAsStringAsync(dest, base64, {
encoding: FileSystem.EncodingType.Base64,
});
return dest;
}
Why split it in two phases? Writing tens of base64 images is slow file IO. I do not want that running inside an exclusive SQLite transaction. So the import does all the file writes up front, collects the new local paths, and only then opens the transaction:
// Phase 1: write every embedded photo to disk, OUTSIDE the transaction.
const restoredByEntry: Record<number, {newPath: string; media_type: string}[]> = {};
for (const entry of importData.data.entries) {
for (const photo of entry.photos ?? []) {
if (!photo.data_base64) continue;
const newPath = await writeBase64ToMediaDir(photo.data_base64, photo.ext);
(restoredByEntry[entry.id] ??= []).push({ newPath, media_type: photo.media_type });
}
}
// Phase 2: one exclusive transaction inserts rows pointing at the NEW paths.
await db.withExclusiveTransactionAsync(async () => {
// ...insert entries, then for each entry insert its restored media rows
});
The transaction inserts entry_media rows that point at the paths I just wrote on THIS device. The original file_path from the backup never gets used as a real location. It rides along as a debugging reference and nothing more.
Make it survive bad input
Real backups are messy. A user deleted a photo after their last export. A file got truncated. An old v1 backup is the only one they have. None of that should blow up the whole restore.
The rule: every step fails soft.
- A source file that is missing or unreadable on export is skipped with a warning. One gone image never fails the export.
- A base64 write that fails on import is skipped. That entry just loses one photo instead of aborting.
- v1 and v2 backups still import. They keep the old path-reference behavior, best effort, exactly as before. The version number routes them down the legacy branch.
A backup or restore should degrade one record at a time, never crash on the whole file. The person running an import is often a person whose phone just died. That's the worst moment to throw an exception.
The tradeoff is real and it is fine
Base64 inflates the payload by about 33 percent. A library of photos turns a small JSON file into a chunky one. I went back and forth on this and landed on: correctness wins. A backup of tens of megabytes is still easy to share or save to a drive. A 4 KB backup that silently loses every image is worthless.
If size ever becomes a real problem, the fix is a zip container with the images as separate files, not a clever pointer scheme.
A few edges I did not wave away. Building the export holds every photo's base64 in one JSON string, so a library of hundreds of images is a memory and payload question, not a free lunch. A zip-streamed container is the next step if that ever bites. The restored extension is derived from the original filename and defaults to jpg, never trusted blindly, and every restored file gets a fresh generated name so paths can never collide on the new device.
The takeaway
Test the one path that matters: export on one install, wipe it, import on a clean install. Not a re-import on the same device. That happy path lies to you.
If your photos, attachments, or any file-backed data do not survive a different machine, your export is a list of broken pointers wearing a backup's clothes.
This came out of SoulSync, a mood tracker that keeps everything on the device with no account and no cloud. When the data never leaves the phone, the backup file is the only way out, so it has to be real.
Carry the bytes.