Front-end Engineering Lab

Download Stream Saver

Download large files using Streams API without loading everything into RAM.

The Problem

Downloading a 2GB file with response.blob() loads the entire file into memory, causing crashes on devices with limited RAM.

Solution

async function downloadWithStreams(url: string, filename: string): Promise<void> {
  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error(`Download failed: ${response.statusText}`);
  }

  const contentLength = response.headers.get('Content-Length');
  const total = contentLength ? parseInt(contentLength, 10) : 0;
  
  let loaded = 0;
  const chunks: Uint8Array[] = [];
  
  const reader = response.body?.getReader();
  if (!reader) {
    throw new Error('ReadableStream not supported');
  }

  // Read stream in chunks
  while (true) {
    const { done, value } = await reader.read();
    
    if (done) break;
    
    chunks.push(value);
    loaded += value.byteLength;
    
    // Show progress
    if (total) {
      const percent = (loaded / total) * 100;
      console.log(`Downloaded: ${percent.toFixed(1)}%`);
    }
  }

  // Create blob and download
  const blob = new Blob(chunks);
  const downloadUrl = URL.createObjectURL(blob);
  
  const a = document.createElement('a');
  a.href = downloadUrl;
  a.download = filename;
  a.click();
  
  setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
}

// With File System Access API (save directly to disk)
interface FileSystemFileHandle {
  createWritable(): Promise<FileSystemWritableFileStream>;
}

interface FileSystemWritableFileStream extends WritableStream {
  write(data: BufferSource | Blob | string): Promise<void>;
  close(): Promise<void>;
}

async function downloadDirectToDisk(url: string, suggestedName: string): Promise<void> {
  // Check browser support
  if (!('showSaveFilePicker' in window)) {
    throw new Error('File System Access API not supported');
  }

  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error(`Download failed: ${response.statusText}`);
  }

  // Show save dialog
  const windowWithFS = window as Window & {
    showSaveFilePicker?: (options: {
      suggestedName: string;
      types: Array<{ description: string; accept: Record<string, string[]> }>;
    }) => Promise<FileSystemFileHandle>;
  };
  
  const handle = await windowWithFS.showSaveFilePicker!({
    suggestedName,
    types: [{
      description: 'All Files',
      accept: { '*/*': [] }
    }]
  });

  // Create writable stream
  const writable = await handle.createWritable();
  
  // Pipe response directly to file (never enters RAM)
  await response.body?.pipeTo(writable);
  
  console.log('File saved successfully');
}

// Chunked download with retry
class ChunkedDownloader {
  async download(
    url: string,
    filename: string,
    chunkSize = 5 * 1024 * 1024 // 5MB chunks
  ): Promise<void> {
    // Get file size
    const headResponse = await fetch(url, { method: 'HEAD' });
    const contentLength = headResponse.headers.get('Content-Length');
    
    if (!contentLength) {
      throw new Error('Server does not support range requests');
    }
    
    const fileSize = parseInt(contentLength, 10);
    const chunks: Uint8Array[] = [];
    
    // Download in chunks
    for (let start = 0; start < fileSize; start += chunkSize) {
      const end = Math.min(start + chunkSize - 1, fileSize - 1);
      const chunk = await this.downloadChunk(url, start, end);
      chunks.push(chunk);
      
      const progress = ((end + 1) / fileSize) * 100;
      console.log(`Progress: ${progress.toFixed(1)}%`);
    }
    
    // Combine and save
    const blob = new Blob(chunks);
    this.triggerDownload(blob, filename);
  }

  private async downloadChunk(
    url: string,
    start: number,
    end: number,
    retries = 3
  ): Promise<Uint8Array> {
    for (let attempt = 1; attempt <= retries; attempt++) {
      try {
        const response = await fetch(url, {
          headers: { Range: `bytes=${start}-${end}` }
        });
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        
        const arrayBuffer = await response.arrayBuffer();
        return new Uint8Array(arrayBuffer);
      } catch (error) {
        if (attempt === retries) {
          throw new Error(`Chunk download failed after ${retries} attempts`);
        }
        
        // Exponential backoff
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
      }
    }
    
    throw new Error('Download failed');
  }

  private triggerDownload(blob: Blob, filename: string): void {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 100);
  }
}

// Usage
await downloadWithStreams(
  'https://example.com/large-file.zip',
  'download.zip'
);

// Or direct to disk (Chrome 86+)
await downloadDirectToDisk(
  'https://example.com/video.mp4',
  'my-video.mp4'
);

// Or chunked with retry
const downloader = new ChunkedDownloader();
await downloader.download(
  'https://example.com/huge-file.bin',
  'file.bin',
  10 * 1024 * 1024 // 10MB chunks
);

Performance Note

Benefit: Memory usage stays constant (~10-20MB) regardless of file size. A 2GB download uses the same RAM as a 10MB download. File System Access API writes directly to disk, using near-zero browser memory.

On this page