You mentioned that you're using the latest version of Chrome. In that environment, it's possible to stream directly to disk from a fetch
response using the File System Access API by piping the response's ReadableStream
to the FileSystemWritableFileStream
of a FileSystemFileHandle
.
Here's the description of how this works from the page FileSystemWritableFileStream.write
:
No changes are written to the actual file on disk until the stream has been closed. Changes are typically written to a temporary file instead.
My interpretation of this is that once the temporary file is completely written, it is moved into the place of the selected file handle, overwriting it. (Perhaps someone can clarify this technicality in a comment.) In any case, it seems to satisfy your criteria of "not buffering the entire download stream contents in memory before writing".
Using this method, you'll be responsible for managing all UI indications (start, error, progress, end, etc.) as it won't use the browser's native file download UI: you can hook into stream events using a custom TransformStream
for progress monitoring.
The MDN docs that I've linked to have all the information you need to understand how to use these APIs. However, I also prepared a basic and contrived, but self-contained example that you can run using a static file server on localhost
to see it all working:
streaming-file-download.html
:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Streaming file download simulation</title>
<style>
/* Just some styles for this demo */
body { font-family: sans-serif; } div { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; } textarea { font-size: 1rem; padding: 0.5rem; height: 10rem; width: 20rem; } button { font-size: 1rem; }
</style>
<script type="module">
// Obtain a file handle
async function getFileHandle () {
const opts = {
suggestedName: 'input.txt',
types: [{
description: 'Text file',
accept: {'text/plain': ['.txt']},
}],
};
return window.showSaveFilePicker(opts);
}
// Determine whether or not permission to write is granted
async function getWritePermission (fsFileHandle) {
const writeMode = {mode: 'readwrite'};
if (await fsFileHandle.queryPermission(writeMode) === 'granted') return true;
if (await fsFileHandle.requestPermission(writeMode) === 'granted') return true;
return false;
}
// Mimic fetching a remote resource
// by creating a response from the text input data
function fetchResponse () {
const text = document.querySelector('textarea').value;
return Promise.resolve(new Response(text));
}
// Monitor and react to the progress of a stream using callbacks:
class ProgressStream extends TransformStream {
constructor ({start, progress, end} = {}) {
super({
start () { start?.(); },
transform (chunk, controller) {
const {byteLength} = chunk;
// Critical: this pipes the stream data forward
controller.enqueue(chunk);
progress?.(byteLength);
},
flush () { end?.(); },
});
}
}
let fsFileHandle;
async function saveFile () {
if (!fsFileHandle) {
try { fsFileHandle = await getFileHandle(); }
catch (exception) {
// Handle exception: User cancelled, etc.
// In this demo, we just throw:
throw exception;
}
}
if (!await getWritePermission(fsFileHandle)) {
// Handle condition: User revoked/declined permission
// In this demo, we just throw:
throw new Error('File write permission not granted');
}
const fsWritableStream = await fsFileHandle.createWritable();
const response = await fetchResponse();
await response.body
.pipeThrough(new ProgressStream({
start: () => console.log('Download starting'),
progress: (byteLength) => console.log(`Downloaded ${byteLength} bytes`),
end: () => console.log('Download finished'),
}))
.pipeTo(fsWritableStream); // Will automatically close when stream ends
console.log('Done');
}
const saveButton = document.querySelector('button');
saveButton.addEventListener('click', saveFile);
</script>
</head>
<body>
<h1>See console messages</h1>
<div>
<textarea>Hello world!</textarea>
<button>Save</button>
</div>
</body>
</html>