In Expo SDK 57, how does expo-file-system's File.downloadFileAsync handle an existing destination, a 404 response, progress and cancellation?
answer
- static method, resolves to a File
- Directory target: name from response
- idempotent defaults to false
- non-2xx rejects, no file created
- onProgress plus AbortSignal since SDK 56
basics
~20 sFile.downloadFileAsync(url, destination, options) saves a URL into a File or Directory and resolves to a File. It rejects if the target exists unless idempotent is true, rejects on a non-2xx status, reports onProgress and cancels through an AbortSignal.
solid answer
~40 s`File.downloadFileAsync(url, destination, options)` is a static method on `expo-file-system`'s `File` class. If `destination` is a `File`, that exact path is written; if it is a `Directory`, the file name comes from the response. It resolves to a `File`. By default it rejects with a destination-already-exists error when the target file exists; `idempotent: true` overwrites instead. A non-2xx response rejects with an error carrying the status code, and no file is created. Since SDK 56 the options also take `onProgress`, called with `bytesWritten` and `totalBytes` (`-1` without a Content-Length), and `signal`, an `AbortSignal` that cancels the transfer and rejects with an `AbortError`. On Android a failure mid-transfer can leave a partial file, so I download to a temporary name and `move()` it into place on success.
code
typescript · 27 linesimport { Directory, File, Paths } from 'expo-file-system';
export async function downloadRegion(
regionId: string,
url: string,
onPercent: (p: number) => void,
signal: AbortSignal,
): Promise<File> {
const dir = new Directory(Paths.document, 'offline-regions');
dir.create({ intermediates: true, idempotent: true });
const partial = new File(dir, `${regionId}.zip.part`);
const target = new File(dir, `${regionId}.zip`);
try {
const file = await File.downloadFileAsync(url, partial, {
idempotent: true, // overwrite a leftover .part from an earlier attempt
signal,
onProgress: ({ bytesWritten, totalBytes }) => {
if (totalBytes > 0) onPercent(bytesWritten / totalBytes);
},
});
await file.move(target, { overwrite: true }); // file.uri now points at target
return file;
} catch (e) {
if (partial.exists) partial.delete(); // Android can leave a partial file
throw e;
}
}go deeper
Recall the shape: a static method taking a URL and a File or Directory, resolving to a File, and that the target folder must exist first.
Explain idempotent, the non-2xx rejection, the onProgress payload including totalBytes of -1, and cancellation through an AbortSignal.
Show the production pattern: temporary name plus move on success, because Android can leave a partial file, and cleanup of leftovers on failure or abort.
Discuss when a one-shot download is enough versus resumable or background transfers, weighing user expectations for large offline packs on flaky mobile networks.
## The call `File.downloadFileAsync` is the object API's one-shot download: it fetches a URL and writes the body straight to disk, without the bytes passing through JavaScript. ```ts static downloadFileAsync(url: string, destination: Directory | File, options?: DownloadOptions): Promise<File> ``` - **`destination` as a `File`** writes exactly that path. - **`destination` as a `Directory`** lets the library name the file from the response headers or the URL. - **The promise resolves to a `File`** that points at what was written, so `file.size` and `file.uri` are immediately usable. - **The parent directory must exist.** Create it first with `new Directory(...).create({ intermediates: true, idempotent: true })`, as the official examples do. ## The options | Option | Default | Effect | |---|---|---| | `headers` | none | Request headers, for example an authorization header for a private tile server | | `idempotent` | `false` | When `true`, an existing destination file is overwritten instead of rejected | | `onProgress` | none | Called with `{ bytesWritten, totalBytes }`; `totalBytes` is `-1` when the server sends no `Content-Length` | | `signal` | none | An `AbortSignal`; aborting cancels the native transfer and rejects with an error named `AbortError` | `onProgress` and `signal` arrived in SDK 56. Before that, progress needed the legacy `createDownloadResumable`. ## What happens when things go wrong 1. **The destination exists.** The promise rejects with a `DestinationAlreadyExists` error unless `idempotent: true` is set. Re-running a download after a crash therefore fails loudly by default, which is deliberate: silently replacing a file the user saved is worse. 2. **The server answers 404 or 500.** The promise rejects with an `UnableToDownload` error whose message includes the status code, and no file is created. The error page body is never saved as if it were the file. 3. **The connection drops mid-transfer.** Here the platforms differ. On **Android** the body streams directly into the target file, so a partially written file may remain. On **iOS** the download completes in a temporary location and is moved into place only on success, so nothing is left behind. 4. **The user cancels.** Aborting the signal cancels the native task and the promise rejects with `AbortError`, which the UI can treat as a non-error. Point 3 is the one that bites in production: on Android a truncated archive can sit under its final name and later fail to open. The robust pattern is to download to a temporary name and rename on success. ## The offline-region download, done properly For an app that downloads a map region pack before a hike: - download to `<region>.zip.part` with `idempotent: true`, so a leftover from an earlier attempt is overwritten; - drive a progress bar from `onProgress`, ignoring the percentage when `totalBytes` is `-1`; - wire a Cancel button to an `AbortController`; - on success, `await file.move(target, { overwrite: true })`, which also updates `file.uri`; - on failure, delete the `.part` file if it exists and rethrow. ## When this method is not enough - **Pause and resume** use `File.createDownloadTask(url, destination, options)` (SDK 56+), whose task has `downloadAsync()`, `pauseAsync()` and `resumeAsync()`. - **Transfers that continue while the app is suspended** belong with background work, not with this call. - **Uploads** go through `file.upload()` or `expo/fetch`, a networking concern. - **The legacy `downloadAsync(uri, fileUri)`** from `expo-file-system/legacy` is the pre-SDK-54 equivalent; imported from the package root it throws.
- Why download to a .part name and rename, if iOS already moves the file into place only on success?Because Android does not: it streams the body into the destination, so a dropped connection can leave a truncated file under the final name. Downloading to a temporary name and calling `move()` after the promise resolves gives both platforms the same guarantee that the final name only ever holds a complete file.
- What should the progress bar show when onProgress reports totalBytes as -1?`-1` means the server sent no Content-Length, so a percentage cannot be computed. Show an indeterminate spinner or the megabytes received from `bytesWritten`, and never divide by `totalBytes` in that case.
- When would you use File.createDownloadTask instead of downloadFileAsync?When the download must be paused and resumed, or its state inspected. `File.createDownloadTask` (SDK 56+) returns a task that does not start until `downloadAsync()` is called and offers `pauseAsync()` and `resumeAsync()`. For a one-shot download with progress and cancel, `downloadFileAsync` is simpler.
saying these in an interview costs you the question
- downloadFileAsync overwrites an existing file by default.
- A 404 response resolves with a file containing the error page.
- A failed download never leaves a partial file on any platform.
- Progress needs the legacy createDownloadResumable even in SDK 57.
- Passing a Directory as the destination throws; it must be a File.