DEV Community

CertosinoLab
CertosinoLab

Posted on

Streaming Large ZIP Archives in the Browser with Vue and the File System Access API

Blob vs streaming, ZIP64, progress tracking, cancellation, and browser fallbacks

Exporting a file from a web application usually looks like a simple operation.

The application generates some data, creates a Blob, builds an object URL, and programmatically clicks a download link.

This approach works well for reports, JSON documents, generated images, and other relatively small outputs. However, it becomes much more problematic when the application needs to export hundreds of megabytes or several gigabytes of data.

A large archive may require the browser to keep source files, compression buffers, temporary byte arrays, and the final Blob alive at the same time. On a device with limited memory, this can slow down the page, trigger aggressive garbage collection, or terminate the browser tab completely.

To explore a different approach, I built StreamZIP Lab, a small frontend project that creates ZIP archives directly in the browser.

The application compares two export strategies:

  • writing the ZIP archive progressively to disk through the File System Access API;
  • generating the complete archive as a Blob before starting a traditional browser download.

The project also covers ZIP64, compression strategies, progress tracking, cancellation, browser compatibility, and the special treatment of formats such as JPEG and MP4.

Everything is implemented in three files:

streamzip-lab/
├── index.html
├── styles.css
└── functions.js
Enter fullscreen mode Exit fullscreen mode

Vue is loaded directly from a CDN, so there is no package manager, bundler, build command, or Single-File Component involved.

This article explains the technical decisions behind the project and some of the less obvious problems involved in generating large archives inside a browser.

What the project does

StreamZIP Lab allows the user to select multiple local files and export them as a single ZIP archive.

The main workflow is:

  1. The user selects or drops one or more files.
  2. The application examines their extensions and MIME types.
  3. Each file is assigned either the DEFLATE or STORE strategy.
  4. The user chooses between streaming and Blob output.
  5. The ZIP archive is generated.
  6. The interface displays per-file and global progress.
  7. The export can be cancelled while it is running.

The application also shows information about the current browser:

Secure context       Yes
showSaveFilePicker   Available
Web Streams          Available
Enter fullscreen mode Exit fullscreen mode

When the File System Access API is supported, the recommended mode is Stream to disk.

When it is not available, the application automatically switches to Blob + download.

The project is related to Progressive Web Apps because direct file interaction is especially useful for installable editors, media tools, local-first applications, backup utilities, and offline-capable applications.

However, the current three-file demo is not, by itself, a complete installable PWA. A production PWA would also require at least a web app manifest and a service worker. StreamZIP Lab focuses specifically on the export and filesystem layer that could be integrated into a larger PWA.

The technology stack

The project uses only standard browser technologies and two external libraries:

  • Vue 3 for the reactive interface;
  • zip.js for ZIP creation;
  • the File System Access API for direct disk output;
  • the Web Streams API for progressive writing;
  • AbortController for cancellation;
  • the traditional Blob download pattern as a fallback.

Vue is loaded through its global production build:

<script src="https://cdn.jsdelivr.net/npm/vue@3.5.40/dist/vue.global.prod.js"></script>
Enter fullscreen mode Exit fullscreen mode

zip.js is loaded in the same way:

<script src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.29/dist/zip-full.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

Finally, the project logic is loaded from the local JavaScript file:

<script src="functions.js"></script>
Enter fullscreen mode Exit fullscreen mode

Vue officially supports this CDN-based approach. The global build exposes APIs such as createApp through the global Vue object and does not require a build step. The main limitation is that Single-File Component syntax is not available.

For a small technical lab, this limitation is acceptable. Keeping the project in three readable files makes it easier to inspect the actual browser APIs without introducing build configuration.

The Vue application starts with:

const { createApp } = Vue;
createApp({
  data() {
    return {
      files: [],
      archiveName: "streamzip-export.zip",
      exportMode: "stream",
      compressionMode: "auto",
      zip64Mode: "auto",
      status: "idle"
    };
  }
}).mount("#app");
Enter fullscreen mode Exit fullscreen mode

Most of the interface state is stored in one application object.

For a larger application, I would probably divide the code into modules or Vue components. For this project, keeping the complete workflow in functions.js makes the relationship between state, browser APIs, and ZIP generation easier to follow.

The traditional Blob export pattern

A common client-side download implementation looks similar to this:

const blob = new Blob([generatedData], {
  type: "application/octet-stream"
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "export.bin";
anchor.click();
URL.revokeObjectURL(url);
Enter fullscreen mode Exit fullscreen mode

The same pattern can be used for a ZIP archive:

const blobWriter = new zip.BlobWriter("application/zip");
const zipWriter = new zip.ZipWriter(blobWriter);
await zipWriter.add(
  "document.txt",
  new zip.TextReader("Hello world")
);
const archiveBlob = await zipWriter.close();
Enter fullscreen mode Exit fullscreen mode

Only after close() returns the finished Blob can the browser download it:

downloadBlob(archiveBlob, "archive.zip");
Enter fullscreen mode Exit fullscreen mode

This approach is simple and compatible with many browsers.

The important detail is that the application needs to finish generating the archive before the download starts.

For a 5 MB archive, this is normally not a problem.

For a 5 GB archive, it may become the central architectural problem.

A Blob is not exactly the same as memory

A Blob represents immutable raw data. It provides metadata such as its size and MIME type and can expose slices or a readable stream.

It is tempting to say that every Blob always exists entirely in RAM, but that explanation is too simplistic.

A browser implementation may internally store Blob data in memory, temporary files, shared buffers, or a combination of these strategies. The exact storage mechanism is an implementation detail.

The real problem is the complete generation pipeline.

When an archive is produced as one final Blob, several resources may coexist:

Source File objects
        ↓
Reader buffers
        ↓
Compression worker buffers
        ↓
Compressed chunks
        ↓
ZIP output buffers
        ↓
Final Blob
        ↓
Object URL
Enter fullscreen mode Exit fullscreen mode

Additional copies can appear when application code:

  • concatenates arrays;
  • converts streams to ArrayBuffer;
  • constructs large Uint8Array values;
  • calls Response.blob();
  • duplicates data between workers and the main thread;
  • retains references longer than necessary.

The final memory peak depends on the browser, the ZIP library, compression settings, file formats, worker implementation, and garbage collection timing.

Therefore, StreamZIP Lab does not claim that a particular input size will always crash a browser.

Instead, it introduces a configurable warning threshold:

const BYTES_IN_MIB = 1024 * 1024;
const BLOB_WARNING_BYTES = 512 * BYTES_IN_MIB;
Enter fullscreen mode Exit fullscreen mode

When the selected files exceed 512 MiB and Blob mode is active, the application shows a warning and requires explicit confirmation.

This is a user-interface safety threshold, not a browser specification limit.

Some devices may handle a much larger archive. Others may struggle with a smaller one, especially on mobile devices or when several memory-intensive tabs are open.

Why streaming changes the architecture

The Web Streams API is designed for data that is created, processed, and consumed incrementally instead of being read entirely into memory. It also provides queuing and backpressure mechanisms.

A streaming ZIP pipeline can be represented as:

File
  ↓
BlobReader
  ↓
Compression
  ↓
ZIP records
  ↓
WritableStream
  ↓
Destination file
Enter fullscreen mode Exit fullscreen mode

Instead of waiting for one complete archive object, the ZIP writer emits chunks as they become available.

Each chunk is passed to the destination stream and written to disk.

The main conceptual difference is:

Blob mode
Read everything
→ generate everything
→ retain final result
→ begin download
Streaming mode
Read a chunk
→ process the chunk
→ write the chunk
→ continue
Enter fullscreen mode Exit fullscreen mode

Streaming does not mean zero memory usage.

The browser and compression library still require buffers. ZIP metadata must also be retained until the central directory can be written.

The objective is to keep memory usage related to the active working set rather than the complete archive size.

Backpressure

Backpressure is one of the most important properties of a streaming pipeline.

Imagine that the ZIP compressor can produce data faster than the filesystem can write it.

Without flow control, compressed chunks would continue accumulating in memory.

A stream communicates that the destination is temporarily unable to accept more data. This signal moves backward through the pipeline, slowing down the producer.

The Streams Standard describes backpressure as the process of normalizing the flow according to the rate at which the chain can process chunks.

Conceptually:

Fast producer
     ↓
Compression
     ↓
Full queue
     ↓
Slow filesystem
The destination applies backpressure
     ↑
The producer slows down
Enter fullscreen mode Exit fullscreen mode

In application code, this is often handled by the stream implementation and the promises returned by write operations.

StreamZIP Lab passes a WritableStream to zip.js. The library then writes ZIP data to that stream while respecting the asynchronous destination.

Opening the save picker

The streaming export starts with showSaveFilePicker():

fileHandle = await window.showSaveFilePicker({
  id: "streamzip-lab-export",
  suggestedName: this.archiveName,
  types: [
    {
      description: "ZIP archive",
      accept: {
        "application/zip": [".zip"]
      }
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

The method opens the native save dialog and returns a FileSystemFileHandle.

The options do not silently force the final location or filename. They provide hints to the browser:

  • suggestedName proposes a default filename;
  • description describes the selectable format;
  • accept associates a MIME type with one or more extensions;
  • id allows the browser to associate similar picker operations.

The File System Access specification recommends providing both MIME types and extensions because operating systems do not all identify file formats in the same way.

The API does not give the page unrestricted access to arbitrary filesystem paths.

The user sees a browser-controlled or operating-system-controlled picker and selects the destination.

User activation must be preserved

showSaveFilePicker() is a privileged operation.

It must be initiated from a user action such as a button click.

This requirement affects the order of the code.

The picker should be opened before long asynchronous work:

async exportWithFileSystemAccess() {
  // Keep this as the first await in the export path.
  const fileHandle = await window.showSaveFilePicker({
    suggestedName: this.archiveName
  });
  // Compression can begin after authorization.
  await this.createArchive(fileHandle);
}
Enter fullscreen mode Exit fullscreen mode

A problematic implementation could do this:

async function exportArchive() {
  const generatedData = await performLongCalculation();
  const handle = await window.showSaveFilePicker({
    suggestedName: "archive.zip"
  });
}
Enter fullscreen mode Exit fullscreen mode

By the time the long calculation finishes, the transient user activation may no longer be valid.

The picker can then fail with a security-related error.

For this reason, StreamZIP Lab keeps showSaveFilePicker() as the first awaited operation in the native streaming path.

The application first asks the user where the archive should be saved. Only after the handle has been obtained does it begin compression.

Creating the writable destination

A FileSystemFileHandle does not write bytes by itself.

The application calls createWritable():

const nativeWritable = await fileHandle.createWritable();
Enter fullscreen mode Exit fullscreen mode

This returns a writable file stream connected to the selected destination.

The project stores a reference to it:

this.activeNativeWritable = nativeWritable;
Enter fullscreen mode Exit fullscreen mode

The reference is needed because cancellation must be able to abort the destination even while zip.js is using an outer stream.

The project creates a small adapter:

const outputStream = new WritableStream({
  write: (chunk) => nativeWritable.write(chunk),
  close: () => nativeWritable.close(),
  abort: (reason) => nativeWritable.abort(reason)
});
Enter fullscreen mode Exit fullscreen mode

The adapter exposes the three relevant operations:

  • write() forwards a generated ZIP chunk;
  • close() finalizes the destination;
  • abort() interrupts the write operation.

The ZIP writer is then created using this stream:

const zipWriter = this.createZipWriter(outputStream);
Enter fullscreen mode Exit fullscreen mode

At this point, zip.js can progressively send archive records to the selected file.

Creating the ZIP writer

The project centralizes ZIP writer configuration:

createZipWriter(destination) {
  const options = {
    bufferedWrite: false,
    dataDescriptorSignature: true
  };
  if (this.zip64Mode === "force") {
    options.zip64 = true;
  }
  return new zip.ZipWriter(destination, options);
}
Enter fullscreen mode Exit fullscreen mode

bufferedWrite: false is important for the purpose of the demo because the destination should receive data progressively instead of waiting for a fully buffered output.

dataDescriptorSignature: true tells the writer to include a signature before data descriptors.

When ZIP64 is forced, zip64 is added to the options. Otherwise, zip.js can determine when ZIP64 structures are necessary.

zip.js is designed for large data sets and supports Web Streams, multi-core compression, and archives larger than 4 GB through ZIP64.

Using a library is important here.

A valid ZIP writer must handle much more than concatenating compressed files:

  • local file headers;
  • compression methods;
  • CRC-32 values;
  • compressed and uncompressed sizes;
  • file names and encodings;
  • timestamps;
  • offsets;
  • data descriptors;
  • central directory entries;
  • end-of-directory records;
  • ZIP64 extra fields and records.

Implementing all of this correctly would turn the project into a ZIP format implementation rather than a File System Access API experiment.

General structure of a ZIP archive

A ZIP archive is not simply a sequence of compressed files.

A simplified archive with two files looks like this:

Local file header: document.txt
Compressed or stored data
Optional data descriptor
Local file header: photo.jpg
Compressed or stored data
Optional data descriptor
Central directory entry: document.txt
Central directory entry: photo.jpg
Optional ZIP64 end record
Optional ZIP64 locator
End of central directory record
Enter fullscreen mode Exit fullscreen mode

The official ZIP specification describes the overall structure as local file records followed by the central directory and the end-of-central-directory structures. It also explicitly states that data descriptors facilitate streaming.

Each local file header appears near the corresponding file data.

It contains information such as:

  • the compression method;
  • modification time;
  • file name;
  • general-purpose flags;
  • CRC-32;
  • compressed size;
  • uncompressed size;
  • optional extra fields.

The central directory is written near the end of the archive.

It contains one entry for every archived file and repeats some information from the local headers. It also includes the offset of each local file record.

This design allows an extractor to read the directory and quickly find the contents of the archive.

Why data descriptors are useful for streaming

When writing a local file header, a ZIP generator would ideally already know:

  • the final CRC-32;
  • the final compressed size;
  • the uncompressed size.

The uncompressed size may be available from the source file.

The compressed size is not necessarily known until compression has finished.

The CRC-32 is also calculated while the input is processed.

A non-streaming implementation can compress the complete file first and then write a fully populated header.

A streaming implementation cannot always do that without buffering the complete result.

ZIP provides another mechanism.

The writer can mark the relevant fields as unavailable in the local header, write the file data, and append a data descriptor containing the final CRC and sizes.

Conceptually:

Local header
  CRC: not known yet
  compressed size: not known yet
Compressed data is streamed
Data descriptor
  CRC: final value
  compressed size: final value
  original size: final value
Enter fullscreen mode Exit fullscreen mode

This is one of the reasons ZIP is suitable for progressive output.

It was designed with structures that permit archive data to be produced in one pass.

The central directory is still written at the end

Streaming does not eliminate the need for finalization.

While files are being written, the ZIP implementation must collect enough metadata to later produce the central directory.

The archive is not complete until the writer is closed:

await this.addEntries(zipWriter);
await zipWriter.close();
Enter fullscreen mode Exit fullscreen mode

Calling close() writes the remaining directory records and final archive structures.

This explains why closing and aborting have different meanings.

close() means:

All entries are valid
→ write the central directory
→ finalize the archive
→ complete the destination
Enter fullscreen mode Exit fullscreen mode

abort() means:

Stop producing data
→ discard pending writes where possible
→ do not finalize the archive
Enter fullscreen mode Exit fullscreen mode

A partially written ZIP without its correct central directory should not be treated as a successful export.

Why ZIP64 is necessary

Classic ZIP records contain several 16-bit and 32-bit fields.

A 32-bit unsigned field can represent values up to:

4,294,967,295
Enter fullscreen mode Exit fullscreen mode

That is 4 GiB minus one byte.

The traditional end-of-central-directory record also uses 16-bit fields for entry counts, creating the well-known limit of 65,535 entries.

ZIP64 extends the format with larger fields.

When a classic field cannot contain the real value, it uses a sentinel such as:

0xFFFFFFFF
Enter fullscreen mode Exit fullscreen mode

or:

0xFFFF
Enter fullscreen mode Exit fullscreen mode

The actual value is stored in a ZIP64 extra field or ZIP64 end-of-directory structure.

The PKWARE specification requires ZIP64 records when classic fields are too small for the required values.

The project displays a simple preliminary indication:

const ZIP32_MAX = 0xffffffff;
zip64Required() {
  return this.totalBytes > ZIP32_MAX
    || this.files.length > 0xffff;
}
Enter fullscreen mode Exit fullscreen mode

This is useful for the interface, but it is only an estimate.

The actual need for ZIP64 depends on values such as:

  • the uncompressed size of an individual entry;
  • its compressed size;
  • the final archive size;
  • the offset of each local header;
  • the central directory size;
  • the number of entries.

The total input size alone does not fully determine every one of these values.

For that reason, the final format decision belongs to the ZIP writer.

The interface offers two modes:

Automatic
Force ZIP64
Enter fullscreen mode Exit fullscreen mode

Automatic mode is appropriate for normal use.

Forced mode is useful for testing whether other archive tools correctly support ZIP64 output.

Adding files to the archive

The application processes files sequentially:

async addEntries(zipWriter) {
  let completedInputBytes = 0;
  for (const item of this.files) {
    const strategy = this.strategyFor(item.file);
    item.status = "processing";
    await zipWriter.add(
      item.archiveName,
      new zip.BlobReader(item.file),
      {
        level: strategy.level,
        signal: this.abortController.signal,
        lastModDate: item.file.lastModified? new Date(item.file.lastModified): new Date(),
        onprogress: (index, max) => {
          this.progress.currentBytes = index;
          this.progress.currentTotal = max || item.file.size;
          this.progress.processedBytes =
            completedInputBytes + index;
        }
      }
    );
    item.status = "done";
    completedInputBytes += item.file.size;
    this.progress.completedFiles += 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

Each browser File is already a type of Blob, so it can be passed to zip.js through BlobReader.

The source file does not need to be converted into a complete ArrayBuffer.

This avoids an unnecessary application-level copy.

Files are added one at a time, which also makes the progress model and status interface easier to understand.

Possible states are:

Pending
Running
Completed
Cancelled
Error
Enter fullscreen mode Exit fullscreen mode

DEFLATE and STORE

A ZIP entry does not always need to be compressed.

The two strategies used by the project are:

  • DEFLATE, which compresses the input;
  • STORE, which copies the input into the archive without additional compression.

For plain text, CSV, JSON, XML, source code, and other repetitive data, DEFLATE can significantly reduce the output size.

For formats that are already compressed, another DEFLATE pass often produces little or no improvement.

It may even make the archive slightly larger because of compression metadata.

It also consumes CPU, increases export time, and may increase power usage on mobile devices.

The project supports three compression modes:

Automatic
DEFLATE all files
STORE all files
Enter fullscreen mode Exit fullscreen mode

The automatic mode uses an extension and MIME-type heuristic.

Detecting already compressed formats

The project defines a set of commonly compressed extensions:

const ALREADY_COMPRESSED_EXTENSIONS = new Set([
  "7z",
  "aac",
  "avi",
  "avif",
  "br",
  "bz2",
  "docx",
  "epub",
  "flac",
  "gif",
  "gz",
  "heic",
  "heif",
  "jpeg",
  "jpg",
  "m4a",
  "m4v",
  "mkv",
  "mov",
  "mp3",
  "mp4",
  "ogg",
  "ogv",
  "opus",
  "pdf",
  "png",
  "pptx",
  "rar",
  "webm",
  "webp",
  "xlsx",
  "xz",
  "zip"
]);
Enter fullscreen mode Exit fullscreen mode

Audio and video MIME prefixes are also treated as already compressed:

const ALREADY_COMPRESSED_MIME_PREFIXES = [
  "audio/",
  "video/"
];
Enter fullscreen mode Exit fullscreen mode

The detection function combines extension and MIME checks:

isAlreadyCompressed(file) {
  const extension = this.extensionOf(file.name || "");
  const mime = (file.type || "").toLowerCase();
  return ALREADY_COMPRESSED_EXTENSIONS.has(extension)
    || ALREADY_COMPRESSED_MIME_TYPES.has(mime)
    || ALREADY_COMPRESSED_MIME_PREFIXES.some(
      (prefix) => mime.startsWith(prefix)
    );
}
Enter fullscreen mode Exit fullscreen mode

This is a heuristic, not a content analysis algorithm.

A filename can have an incorrect extension.

A browser may provide an empty or inaccurate MIME type.

A PDF can contain highly compressible streams, already compressed images, or a mixture of content.

An Office Open XML document is itself a ZIP-based container, so applying DEFLATE to the complete .docx or .xlsx file normally offers limited benefit.

For a lightweight client-side application, the heuristic is a practical compromise.

A production application could make this configurable per file or inspect file signatures before choosing the compression method.

Choosing the compression strategy

The final decision is made by strategyFor():

strategyFor(file) {
  if (this.compressionMode === "store") {
    return {
      label: "STORE",
      level: 0,
      reason: "compression disabled"
    };
  }
  if (this.compressionMode === "always") {
    return {
      label: "DEFLATE",
      level: 6,
      reason: "compression forced"
    };
  }
  if (this.isAlreadyCompressed(file)) {
    return {
      label: "STORE",
      level: 0,
      reason: "already compressed format"
    };
  }
  return {
    label: "DEFLATE",
    level: 6,
    reason: "potentially compressible content"
  };
}
Enter fullscreen mode Exit fullscreen mode

A compression level of 6 is used as a reasonable general-purpose balance.

A higher level can reduce output slightly for some inputs but usually requires more processing time.

For JPEG and MP4 files, automatic mode selects level 0, which corresponds to STORE.

The resulting ZIP archive still contains the files normally. They are simply not recompressed.

Progress tracking

Progress reporting for a generated ZIP is less obvious than it initially appears.

The application knows:

  • the size of every source file;
  • the total size of all source files;
  • how many files have completed;
  • the number of input bytes processed for the active file.

It does not necessarily know the final archive size in advance.

The compression ratio depends on the file contents and selected strategy.

For this reason, StreamZIP Lab defines global progress in terms of input bytes processed.

The total size is calculated with:

totalBytes() {
  return this.files.reduce(
    (total, item) => total + item.file.size,
    0
  );
}
Enter fullscreen mode Exit fullscreen mode

The ZIP entry callback updates the current file:

onprogress: (index, max) => {
  this.progress.currentBytes = index;
  this.progress.currentTotal = max || item.file.size;
  this.progress.processedBytes = Math.min(
    this.totalBytes,
    completedInputBytes + index
  );
}
Enter fullscreen mode Exit fullscreen mode

The percentage is then:

overallPercent() {
  if (!this.totalBytes) {
    return this.status === "success"? 100: 0;
  }
  const value = Math.round(
    this.progress.processedBytes
    / this.totalBytes
    * 100
  );
  return Math.min(100, Math.max(0, value));
}
Enter fullscreen mode Exit fullscreen mode

This produces a stable and understandable progress indicator.

However, it is important to describe it correctly.

It means:

Percentage of source bytes processed
Enter fullscreen mode Exit fullscreen mode

It does not mean:

Percentage of final ZIP bytes written
Enter fullscreen mode Exit fullscreen mode

These metrics are related but not identical.

For example, a large text file may generate far fewer output bytes than input bytes, while a stored video file may produce almost the same number of input and output bytes.

The interface also displays:

the current filename; current-file progress; • •

  • completed file count;
  • elapsed time;
  • export mode;
  • final Blob size when available.

In native streaming mode, the application does not calculate the final output size because the archive is written directly to disk and no final Blob is returned.

Cancelling the export

Cancellation must affect more than the interface.

Changing the status label to “Cancelled” while compression continues in the background would not be a real cancellation mechanism.

The project creates an AbortController at the start of every export:

this.abortController = new AbortController();
Enter fullscreen mode Exit fullscreen mode

Its signal is passed to every zip.js entry:

await zipWriter.add(
  item.archiveName,
  new zip.BlobReader(item.file),
  {
    level: strategy.level,
    signal: this.abortController.signal,
    onprogress
  }
);
Enter fullscreen mode Exit fullscreen mode

When the user clicks Cancel export, the controller is aborted:

cancelExport() {
  if (!this.isBusy || this.cancelRequested) {
    return;
  }
  this.cancelRequested = true;
  if (
    this.abortController
    &&!this.abortController.signal.aborted
  ) {
    this.abortController.abort(
      this.createAbortError()
    );
  }
  void this.abortNativeWritable(
    this.createAbortError()
  );
}
Enter fullscreen mode Exit fullscreen mode

The application also aborts the native destination:

async abortNativeWritable(reason) {
  const writable = this.activeNativeWritable;
  if (!writable || typeof writable.abort!== "function") {
    return;
  }
  try {
    await writable.abort(reason);
  } catch (error) {
    // The stream may already be closed or aborted.
  }
}
Enter fullscreen mode Exit fullscreen mode

This two-level cancellation is useful because it stops both sides of the operation:

AbortController
 stops ZIP entry processing
nativeWritable.abort()
 stops destination writing and cleanup
Enter fullscreen mode Exit fullscreen mode

The Streams Standard defines abortion as an error transition for the writable stream and its pending operation.

After cancellation, the project does not call the normal archive finalization path.

The destination should therefore not be presented as a successful ZIP archive.

The exact appearance of an aborted destination can vary by browser and operating system. It may be removed, left empty, or remain as an incomplete file depending on implementation details and when the operation was interrupted.

The important application rule is that an aborted file must never be reported as a completed export.

Handling picker cancellation

The user can also cancel the save dialog before the export begins.

This normally appears as an AbortError.

The project distinguishes this expected case from operational failures:

isAbortError(error) {
  return Boolean(
    error
    && (
      error.name === "AbortError"
      || /abort|cancel/i.test(
        this.errorMessage(error)
      )
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

The main export method maps it to the cancelled state:

catch (error) {
  if (
    this.isAbortError(error)
    || this.cancelRequested
  ) {
    this.status = "cancelled";
    this.addLog(
      "Export cancelled. The destination file was not finalized."
    );
  } else {
    this.status = "error";
    this.addLog(
      `Error: ${this.errorMessage(error)}`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Cancelling a picker is not a system failure.

The user simply decided not to continue.

Treating it as a normal cancellation produces a better interface than displaying a red error message.

The Blob fallback

The application detects native file picker support with:

const supportsFileSystemAccess =
  "showSaveFilePicker" in window;
Enter fullscreen mode Exit fullscreen mode

The complete support object is:

support: {
  secureContext: window.isSecureContext,
  fileSystemAccess:
    "showSaveFilePicker" in window,
  streams:
    "ReadableStream" in window
    && "WritableStream" in window
}
Enter fullscreen mode Exit fullscreen mode

When the native picker is unavailable, the application switches to Blob mode:

if (!this.support.fileSystemAccess) {
  this.exportMode = "blob";
  this.addLog(
    "File System Access API unavailable: Blob fallback enabled."
  );
}
Enter fullscreen mode Exit fullscreen mode

The fallback uses BlobWriter:

async exportAsBlob() {
  this.status = "running";
  const blobWriter =
    new zip.BlobWriter("application/zip");
  const zipWriter =
    this.createZipWriter(blobWriter);
  await this.addEntries(zipWriter);
  const archiveBlob =
    await zipWriter.close();
  this.lastRun.outputBytes =
    archiveBlob.size;
  this.downloadBlob(
    archiveBlob,
    this.archiveName
  );
}
Enter fullscreen mode Exit fullscreen mode

The download helper creates a temporary object URL:

downloadBlob(blob, fileName) {
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  anchor.href = url;
  anchor.download = fileName;
  anchor.rel = "noopener";
  anchor.style.display = "none";
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  window.setTimeout(
    () => URL.revokeObjectURL(url),
    30_000
  );
}
Enter fullscreen mode Exit fullscreen mode

The URL is not revoked immediately after click().

A short delay gives the browser enough time to begin handling the download before the reference is released.

The fallback maintains functionality on more browsers, but it does not reproduce the memory characteristics of direct streaming.

This distinction is explicitly shown in the interface.

Permissions and security

The name “File System Access API” can create the wrong impression.

A web page does not receive unrestricted access to the user’s computer.

For the picker-based workflow:

The page requests a picker. 1.

  1. The browser displays a user-controlled dialog.
  2. The user selects a file destination.
  3. The page receives a handle for that selected file.
  4. The page can write through the granted handle.

Access is explicitly gated by the picker. The specification also recommends that browsers restrict sensitive files and directories, including system locations and browser data directories.

The project never receives a traditional absolute path such as:

C:\Users\Name\Documents\archive.zip
Enter fullscreen mode Exit fullscreen mode

or:

/home/name/Documents/archive.zip
Enter fullscreen mode Exit fullscreen mode

The browser returns an opaque handle.

This separation prevents the application from depending on operating-system-specific path structures and limits what the site can access.

The project also normalizes suggested archive names:

normalizeArchiveName() {
  const cleaned = (
    this.archiveName
    || "streamzip-export.zip"
  ).replace(
      /[\\/:*?"<>|\u0000-\u001f]/g,
      "_"
    ).trim();
  this.archiveName =
    cleaned.toLowerCase().endsWith(".zip")? cleaned: `${cleaned || "streamzip-export"}.zip`;
}
Enter fullscreen mode Exit fullscreen mode

This is not a replacement for browser security.

It is an application-level measure that avoids suggesting names containing characters commonly invalid on desktop operating systems.

The browser still has the final authority over accepted names and destinations.

Secure contexts

Powerful browser capabilities generally require a secure context.

The project checks:

window.isSecureContext
Enter fullscreen mode Exit fullscreen mode

For development, the recommended setup is a local HTTP server:

python -m http.server 8000
Enter fullscreen mode Exit fullscreen mode

The application can then be opened at:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

Localhost is treated as a trustworthy development context by modern browsers.

For production, the application should be served over HTTPS.

Opening the HTML file directly from the filesystem is not the best testing method because browser behavior, module loading, security policies, and file API support may differ from a normal hosted environment.

A local server also more closely represents how the project will behave after deployment.

Application state

The project uses an explicit state machine:

idle
preparing
running
success
cancelled
error
Enter fullscreen mode Exit fullscreen mode

The states are reflected in both the interface and the export logic.

At the beginning of a run:

this.status = "preparing";
Enter fullscreen mode Exit fullscreen mode

After the destination is authorized:

this.status = "running";
Enter fullscreen mode Exit fullscreen mode

When the archive is finalized:

this.status = "success";
Enter fullscreen mode Exit fullscreen mode

An expected interruption produces:

this.status = "cancelled";
Enter fullscreen mode Exit fullscreen mode

An unexpected exception produces:

this.status = "error";
Enter fullscreen mode Exit fullscreen mode

This separation prevents several interface problems:

  • starting two exports at the same time;
  • removing files during compression;
  • enabling the export button without files;
  • showing success after cancellation;
  • leaving a file marked as running after an error.

The busy state is computed from the current status:

isBusy() {
  return this.status === "preparing"
    || this.status === "running";
}
Enter fullscreen mode Exit fullscreen mode

Buttons and inputs bind their disabled state to this value.

Duplicate and unsafe filenames

Multiple selected files can have the same name.

This is common when files are selected from different directories.

A ZIP archive can technically contain duplicate names, but the extraction behavior can be confusing. Some tools overwrite one entry, while others show both.

StreamZIP Lab generates unique archive names:

photo.jpg
photo (2).jpg
photo (3).jpg
Enter fullscreen mode Exit fullscreen mode

The logic separates the base name from the extension and increments a counter until an unused name is found.

It also replaces characters that commonly create cross-platform filename problems.

This does not preserve the original directory hierarchy because the project receives a flat file selection.

A future version could use directory selection and store relative paths inside the archive.

That extension would require additional care to prevent unsafe archive paths such as ../, absolute paths, or platform-specific traversal patterns.

Technical logging

The application includes a small log panel.

Typical messages are:

Added 4 file(s) (1.8 GB).
Starting export in Stream to disk.
Destination authorized: streamzip-export.zip.
document.txt: DEFLATE (potentially compressible content).
video.mp4: STORE (already compressed format).
Archive completed successfully.
Enter fullscreen mode Exit fullscreen mode

Logging is particularly useful for browser APIs because the user may otherwise see only a generic failure.

The log helps distinguish between:

  • CDN loading errors;
  • picker cancellation;
  • missing secure context;
  • unsupported streams;
  • compression failure;
  • destination write failure;
  • explicit cancellation.

The application limits the log to 80 entries:

if (this.logs.length > 80) {
  this.logs.length = 80;
}
Enter fullscreen mode Exit fullscreen mode

This prevents an indefinitely growing reactive array during repeated tests.

The three project files

index.html

index.html contains the Vue template and external scripts.

The interface is divided into:

  • browser support;
  • file selection;
  • export configuration;
  • progress;
  • technical log;
  • educational explanations.

Because the global Vue build compiles templates in the browser, directives can be written directly in the HTML:

<button
  class="button button-primary"
  type="button"
  @click="exportArchive":disabled="!canExport"
>
  Export ZIP archive
</button>
Enter fullscreen mode Exit fullscreen mode

Reactive values are displayed with normal Vue interpolation:

<strong>{{ overallPercent }}%</strong>
Enter fullscreen mode Exit fullscreen mode

styles.css

styles.css implements the complete visual layer.

It contains:

  • the responsive page layout;
  • drag-and-drop states;
  • form controls;
  • file status chips;
  • progress bars;
  • warning panels;
  • technical metrics;
  • archive structure diagrams;
  • accessible hidden labels.

The CSS is deliberately independent from a component framework.

This keeps the project free from another runtime dependency and makes it possible to study the application state without utility-class noise.

functions.js

functions.js contains:

  • Vue initialization;
  • file management;
  • filename normalization;
  • format detection;
  • compression selection;
  • picker integration;
  • stream adaptation;
  • ZIP generation;
  • progress calculation;
  • cancellation;
  • Blob downloads;
  • error handling;
  • logging;
  • size formatting.

The entire JavaScript file is wrapped in an immediately invoked function:

(() => {
  "use strict";
  // Application code
})();
Enter fullscreen mode Exit fullscreen mode

This prevents project constants and helper functions from being added directly to the global scope.

Vue and zip.js remain global because they are loaded through classic script tags.

Testing the project

A useful test plan should include several different file types and sizes.

Test 1: compressible text

Select a large .txt, .csv, or .json file.

Expected result:

Strategy: DEFLATE
Enter fullscreen mode Exit fullscreen mode

The final archive should be significantly smaller if the input contains repeated data.

Test 2: JPEG image

Select a .jpg file.

Expected result:

Strategy: STORE
Enter fullscreen mode Exit fullscreen mode

The ZIP output should be only slightly larger than the image because the ZIP still needs headers and directory records.

Test 3: MP4 video

Select a large .mp4 file.

Expected result:

Strategy: STORE
Enter fullscreen mode Exit fullscreen mode

This is a good test for progressive writing because the input may be large while additional compression is unnecessary.

Test 4: forced compression

Choose DEFLATE all files and export the same JPEG or MP4.

Compare:

  • elapsed time;
  • output size;
  • CPU usage;
  • responsiveness.

In many cases, the additional work produces very little size reduction.

Test 5: Blob mode

Export the same set of files through Blob mode.

Observe that the browser download starts only after the complete archive has been created.

Test 6: cancellation

Begin exporting a large file and press Cancel export.

The interface should enter the cancelled state and the archive should not be reported as complete.

Test 7: unsupported API

Run the application in a browser without showSaveFilePicker() support.

The destination option should automatically fall back to Blob mode.

Test 8: duplicate names

Select several files with identical names from different directories.

The application should assign unique ZIP entry names.

Test 9: ZIP64

Force ZIP64 and verify the result with an archive utility that exposes format details.

For a real automatic ZIP64 test, the project needs either a very large entry, a very large archive, or more than 65,535 entries.

Limitations of the current implementation

StreamZIP Lab is intentionally small, so it has several limitations.

Browser support

The native picker is not available in every browser.

Feature detection is mandatory, and Blob fallback remains necessary.

CDN dependency

Vue and zip.js are loaded from a CDN.

The application therefore requires network access on its first load unless those files are cached externally.

A real offline PWA should self-host or precache pinned library versions.

No directory hierarchy

The project accepts a flat list of files.

It does not currently reproduce directory structures inside the ZIP.

Progress is based on input bytes

The displayed percentage represents source processing, not exact destination bytes.

No persistent handles

The file handle is used only for the current operation.

The project does not store it in IndexedDB for later reuse.

No encryption

The generated archive is not password-protected or encrypted.

No integrity verification after writing

The project trusts successful completion of the ZIP writer and destination stream.

It does not reopen the final archive and verify every entry.

Main interface and compression workload

zip.js can use workers, but very large or complex workloads still require testing on low-end devices.

Production applications should measure responsiveness under realistic conditions.

Improvements for a production PWA

The project could be extended in several directions.

Add a manifest and service worker

This would turn the browser lab into an installable PWA.

The service worker could cache:

  • index.html;
  • styles.css;
  • functions.js;
  • Vue;
  • zip.js;
  • icons and manifest assets.

Self-host dependencies

Pinned local library files would reduce CDN dependency and simplify a strict Content Security Policy.

Add directory selection

showDirectoryPicker() could be used to recursively collect files and preserve their relative paths.

Persist authorized handles

File and directory handles can be stored for workflows where the user returns to the same export destination.

Permission state must still be checked when the application starts again.

Add per-file compression controls

The user could override automatic decisions for individual files.

Add a worker-based orchestration layer

A dedicated worker could coordinate preprocessing, checksums, metadata generation, or transformations without blocking interface updates.

Add output verification

After writing, the application could reopen the archive, read its central directory, and verify expected names, sizes, and CRC values.

Add reproducible benchmarks

A benchmark mode could compare streaming and Blob output using:

  • identical inputs;
  • compression level;
  • elapsed time;
  • JavaScript heap measurements where available;
  • output size;
  • browser version;
  • device information.

The current risk indicator is educational. It is not a substitute for controlled performance measurements.

Final thoughts

StreamZIP Lab started as a small experiment with showSaveFilePicker(), but building the export workflow required considering several connected topics:

  • browser memory behavior;
  • stream backpressure;
  • user activation;
  • filesystem permissions;
  • ZIP record structure;
  • ZIP64 limits;
  • compression strategy;
  • progress semantics;
  • cancellation;
  • compatibility fallbacks.

The most important architectural difference is not simply replacing one API call with another.

Blob output and streaming output represent two different lifecycle models.

With a Blob, the application normally completes the artifact first and delivers it afterward.

With streaming, generation and persistence happen at the same time.

For large browser-generated files, this difference can determine whether the export is practical at all.

The File System Access API makes browser applications feel closer to desktop software, but it keeps the user in control through explicit file pickers and permission boundaries.

Combined with Web Streams and a ZIP implementation that supports progressive output, it becomes possible to create large archives without intentionally materializing the complete result as one application-managed Blob.

The Blob fallback is still valuable for compatibility.

The main design principle is to treat it as a fallback with different memory characteristics, not as an equivalent implementation.

The complete source code is available here:

https://github.com/sfestacatenate/StreamZipLab_Vue_Javascript

Thank you for reading!

Top comments (0)