In Suno's "Create" screen you can attach your own audio file and then keep writing lyrics and styles. In my open-source Suno clone I wanted that draft to survive a page reload, including the audio file.
Saving the text is easy: put it in AsyncStorage. The file is the hard part. A file picked in the browser gives you a blob: URL, and that URL dies with the page. Storing a few megabytes of base64 in localStorage isn't an option either (around 5 MB total, synchronous, and it blocks the main thread).
Here is the approach that works on both web and native with one interface.
Live demo: https://migrant620.github.io/suno/ · Source (MIT): https://github.com/migrant620/suno-clone
One interface, two files
Metro (Expo's bundler) picks name.web.ts over name.ts when building for web. So I have:
src/draftAudioFiles.ts // native: expo-file-system
src/draftAudioFiles.web.ts // web: Cache Storage
Both export the same three functions:
saveDraftAudioFile(id: string, source: AudioSource): Promise<void>
readDraftAudioFile(id: string): Promise<{ source: { uri: string }; release: () => void }>
removeDraftAudioFile(id: string): Promise<void>
The rest of the app imports ./draftAudioFiles and never knows which one it got.
Web: put the file in Cache Storage
The Cache API (caches) is usually associated with service workers, but it is available on any secure page. It stores real Response objects, so binary data stays binary and you don't need IndexedDB boilerplate:
const CACHE = 'draft-audio:v1';
const key = (id: string) =>
new URL(`/__draft_audio__/${encodeURIComponent(id)}`, location.origin).href;
export async function saveDraftAudioFile(id: string, source: AudioSource) {
const uri = typeof source === 'string' ? source : source?.uri;
if (!uri) throw new Error('Missing audio file');
const blob = await (await fetch(uri)).blob(); // works for blob: URLs too
if (!blob.size) throw new Error('Audio file is empty');
const cache = await caches.open(CACHE);
await cache.put(key(id), new Response(blob, {
headers: { 'Content-Type': blob.type || 'audio/mpeg' },
}));
}
The key is a fake same-origin URL. Nothing is ever served from it; it just has to be a valid URL.
Reading it back creates a fresh object URL, which the audio player can use like any other source:
export async function readDraftAudioFile(id: string) {
const res = await (await caches.open(CACHE)).match(key(id));
if (!res) throw new Error('Saved audio is missing');
const uri = URL.createObjectURL(await res.blob());
return { source: { uri }, release: () => URL.revokeObjectURL(uri) };
}
Note the release function. Object URLs keep the blob in memory until you revoke them, so the hook that restores the draft calls release() when it unmounts.
Native: copy it into the documents folder
On iOS and Android the picker returns a file in a temporary cache directory that the OS may clear. Copy it somewhere permanent:
import * as FileSystem from 'expo-file-system/legacy';
const dir = `${FileSystem.documentDirectory}draft-audio/`;
export async function saveDraftAudioFile(id: string, source: AudioSource) {
const uri = typeof source === 'string' ? source : source?.uri;
if (!uri) throw new Error('Missing audio file');
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
const target = dir + encodeURIComponent(id);
if (uri !== target) await FileSystem.copyAsync({ from: uri, to: target });
const info = await FileSystem.getInfoAsync(target);
if (!info.exists || !info.size) throw new Error('Audio file is empty');
}
Checking the size after copying catches the case where the copy "succeeds" but writes nothing.
The draft hook: metadata in AsyncStorage, file by id
The draft itself stores only the track's metadata (id, title, styles) and the edit settings. The source field is stripped before saving:
const { source, cover, ...track } = audio;
await AsyncStorage.setItem(KEY, JSON.stringify({ track, edit }));
On startup the hook reads that JSON, validates it, and asks readDraftAudioFile(track.id) for the file. Things I learned the hard way:
- Validate what you read. Old versions of the app, or a user poking at storage, can leave JSON with a different shape. Check the fields you need and treat anything else as "no draft".
- Tell the user when restoring fails. If the metadata exists but the file is gone (the browser cleared site data, for example), show "Your saved audio could not be restored. Select the file again", plus a way to discard it. Silently dropping the attachment is worse.
- Serialise writes. Picking a new file quickly after another can start two saves at once. I run every read/write for this key through a small promise-chain lock, so they happen in order.
- Clean up the old file. When the user replaces or removes the attachment, delete the previous file by id after the new metadata is saved, not before. If the save fails, the old draft is still intact.
- Skip no-op writes. Compare the serialised draft to the last one written and return early if they're equal. Otherwise every re-render writes to storage.
Limits
- Cache Storage is per origin and can be evicted under storage pressure.
navigator.storage.persist()can ask the browser to keep it, but browsers may say no. - Private browsing modes may give you a cache that is wiped when the window closes. That's fine for a draft.
-
cachesonly exists on secure origins (HTTPS or localhost).
Try it
Open https://migrant620.github.io/suno/, go to Create, attach an audio file, then reload the page. The attachment is still there. The code is in src/useAudioDraft.ts and src/draftAudioFiles.web.ts.
This is one of six app UIs I've rebuilt with React Native + Expo (Airbnb, Threads, Luma, Suno, Tiimo, Structured), all MIT-licensed at https://migrant620.github.io/.
Earlier in this series: Tiimo's timer ring with one SVG arc · fixing blank Expo web pages on slow networks · a background that follows the cover image.
Top comments (0)