Handling Large File Uploads with File System Access API
This page is part of File System Access API: Read and Write Workflows, which itself sits under the Native Device Integration Patterns reference.
Progressive web apps that attempt to upload multi-gigabyte media or dataset files routinely fail in ways that are hard to diagnose. The browser gives no helpful error UI — the tab crashes, the request stalls silently, or the UI freezes for tens of seconds. This page isolates the exact engine-level causes and provides a reproducible streaming fix that works within the constraints of the File System Access API’s permission and memory model.
Feature Detection Gate
Before any File System Access API call, confirm both the API and a secure context are present. Without this gate, your error handling cannot distinguish a browser that lacks the API from one that has it but received a bad file path.
export function isFileSytemAccessSupported() {
return (
typeof window !== 'undefined' &&
window.isSecureContext === true &&
'showOpenFilePicker' in window
);
}
If isFileSystemAccessSupported() returns false, fall back to a hidden <input type="file"> element. The async payload formatting patterns page covers how to structure that fallback without duplicating upload logic.
Solution Walkthrough
Step 1 — Acquire the handle inside a user gesture
The File System Access API requires a transient user activation. Call showOpenFilePicker directly from a click or pointerdown handler; never defer it into a setTimeout or a Promise chain that began outside the event.
export async function pickLargeFile() {
if (!isFileSytemAccessSupported()) {
throw new DOMException('File System Access API unavailable', 'NotSupportedError');
}
const [fileHandle] = await window.showOpenFilePicker({
multiple: false
});
return fileHandle;
}
Step 2 — Stream to fetch, never buffer
Replace file.arrayBuffer() with file.stream() and pass the ReadableStream as the fetch body. This bypasses the heap entirely — the browser pipes bytes from the file system directly into the network layer.
The duplex: 'half' option is required when sending a streaming body; without it, Chrome 105+ throws TypeError: Failed to construct 'Request'.
export async function streamLargeFile(fileHandle, uploadUrl, { onProgress, signal } = {}) {
if (!window.isSecureContext) {
throw new Error('File System Access API requires HTTPS or localhost.');
}
const controller = new AbortController();
signal?.addEventListener('abort', () => controller.abort(), { once: true });
try {
const file = await fileHandle.getFile();
const stream = file.stream();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': file.type || 'application/octet-stream'
// Omit Content-Length when streaming: let the browser set Transfer-Encoding: chunked
},
body: stream,
duplex: 'half', // Required for streaming bodies — Chrome 105+, Edge 105+; not Safari
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
return response;
} catch (err) {
if (err.name === 'NotAllowedError') {
// Permission was revoked mid-stream (tab backgrounded or handle invalidated)
await fileHandle.requestPermission({ mode: 'read' });
// The caller must retry from byte 0 after permission is re-granted.
// For resumable transfers, integrate a server-side offset via the TUS protocol.
}
throw err;
}
}
Step 3 — Track progress without blocking the compositor
Calling framework state setters (e.g. React’s setState) inside a tight stream read loop triggers synchronous re-renders on every chunk, which blocks the compositor and drops frames. Throttle updates to approximately 60 fps using requestAnimationFrame.
export async function trackStreamProgress(readableStream, totalBytes, onProgress) {
const reader = readableStream.getReader();
let loaded = 0;
let lastUpdate = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
requestAnimationFrame(() => onProgress(loaded, totalBytes));
return;
}
loaded += value.byteLength;
const now = performance.now();
if (now - lastUpdate > 16) {
const snapshot = loaded;
requestAnimationFrame(() => onProgress(snapshot, totalBytes));
lastUpdate = now;
}
}
}
For React apps, prefer offloading stream consumption to a Worker and forwarding progress deltas via postMessage — this removes upload I/O from the main thread entirely.
Step 4 — Pause and resume on visibility change
iOS aggressively suspends network activity when the PWA enters the background. Wrap the upload in a visibilitychange listener so the ReadableStream reader is paused when the tab is hidden and resumed — after re-validating the FileSystemHandle permission — when it returns to the foreground.
export async function uploadWithVisibilityGuard(fileHandle, uploadUrl) {
if (!isFileSytemAccessSupported()) {
throw new DOMException('File System Access API unavailable', 'NotSupportedError');
}
const controller = new AbortController();
const handleVisibilityChange = async () => {
if (document.visibilityState === 'hidden') {
controller.abort();
} else {
// Re-validate permission after returning to foreground
const permissionState = await fileHandle.queryPermission({ mode: 'read' });
if (permissionState !== 'granted') {
await fileHandle.requestPermission({ mode: 'read' });
}
// Note: once aborted, the fetch cannot be resumed in-place.
// This pattern is most useful for pause/resume with a server-side byte-range protocol.
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
try {
return await streamLargeFile(fileHandle, uploadUrl, { signal: controller.signal });
} finally {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
}
Failure Modes and Recovery
| Error name | User-visible symptom | Cause | Minimal fix |
|---|---|---|---|
RangeError: Array buffer allocation failed |
Tab crash or silent hang | file.arrayBuffer() called on a file exceeding the ~2 GB WebKit heap limit |
Replace with file.stream() |
NotAllowedError |
Upload stops mid-transfer | FileSystemHandle permission revoked while tab was backgrounded |
Call fileHandle.requestPermission({ mode: 'read' }) and retry |
TypeError: Failed to construct 'Request' |
fetch throws synchronously | duplex: 'half' missing from streaming fetch options |
Add duplex: 'half' to the fetch init object |
AbortError |
Upload cancelled with no user action | visibilitychange fired (tab hidden) or caller signal fired |
Implement pause/resume logic; re-trigger from last confirmed offset |
| Network stall at 100% | Progress bar reaches 100% but the server never receives the complete body | Server does not support Transfer-Encoding: chunked or HTTP/2 streaming |
Configure the server to accept chunked bodies; verify with curl --data-binary @large.bin |
Web Share interoperability note: FileSystemFileHandle objects cannot be passed directly to navigator.share(). The structured clone algorithm does not serialise native handles. Extract the File object with await fileHandle.getFile() first, then include it in the files array passed to navigator.share().
Browser and Platform Caveats
The duplex: 'half' streaming body path is supported in Chrome 105+ and Edge 105+ on both desktop and Android. As of mid-2026, Safari does not support streaming fetch request bodies — the browser silently buffers the entire body before transmitting, negating the memory benefit. Detect this limitation and fall back to a chunked multipart upload for Safari: split the file into fixed-size Blob slices (e.g. 10 MB each) and POST them sequentially, letting the server reassemble. Firefox does not expose the File System Access API at all; use <input type="file"> with the legacy File API and the same chunked-slice strategy.
For permission management strategies that hold across longer sessions and page navigations, see the handling permission denials gracefully guide.
FAQ
Why does my large file upload fail silently on iOS Safari?
WebKit enforces a ~2 GB limit on ArrayBuffer allocations and restricts background network activity. Use file.stream() chunking and implement visibilitychange listeners to pause and resume transfers when the tab is hidden.
Can I resume an interrupted File System Access API upload without user intervention?
Not automatically. Permission grants are tied to the active tab session and require a user gesture to renew. Implement a server-side resumable upload protocol (e.g. TUS) to retain partial data server-side; then re-acquire the file handle with a new user gesture and resume from the last confirmed byte offset.
How do I prevent React from freezing when tracking upload progress?
Avoid calling setState inside the stream read loop. Use requestAnimationFrame to batch updates at ~60 fps, or offload stream consumption to a Worker and post progress deltas to the main thread via MessageChannel.
Is the File System Access API supported in Firefox or Safari?
Native showOpenFilePicker support is Chromium-only (Chrome and Edge 86+). Firefox and Safari require fallback to <input type="file"> or the legacy File API. Always detect with 'showOpenFilePicker' in window and provide a graceful degradation path.
Related
- File System Access API: Read and Write Workflows — parent guide covering the full read/write lifecycle and permission model
- Async Payload Formatting for Native APIs — structuring multipart and chunked payloads for upload endpoints
- Handling Permission Denials Gracefully — recovery patterns for
NotAllowedErroracross all native browser APIs