---
title: "Browser Sequential Download Blocking — window.open Needs 400ms Gap"
aliases: [browser-download-blocking, sequential-download, window-open-popup-blocker]
tags: [browser, javascript, frontend, download, popup-blocker, gotcha]
sources:
  - "daily/2026-04-30.md"
created: 2026-04-30
updated: 2026-04-30
---

# Browser Sequential Download Blocking — `window.open` Needs 400ms Gap

Browsers suppress sequential `window.open()` calls that happen too close together, treating them as a popup storm. When a "Download All" feature triggers multiple file downloads in a loop, only the first download succeeds — the rest are silently blocked by the browser's popup blocker. Adding a 400ms delay between each call resolves this.

## Key Points

- **Browsers allow only one `window.open()` per user gesture** — subsequent calls in the same synchronous or near-synchronous execution are treated as unsolicited popups and blocked
- **Only the first download succeeds** — there's no error thrown; the remaining files silently disappear
- **The fix:** add a 400ms (or longer) async gap between each download trigger — this allows the browser to process each "gesture" separately
- **`fetch` + `URL.createObjectURL` is the safer alternative** — avoids the popup blocker entirely by keeping the download in the same window context
- The exact threshold varies by browser (Chrome ~300ms, Firefox ~400ms, Safari ~500ms) — use 500ms for cross-browser safety

## Details

### The Problem

```typescript
// ❌ BROKEN — only first download succeeds
async function downloadAll(fileUrls: string[]) {
  for (const url of fileUrls) {
    window.open(url, "_blank");  // ← 2nd, 3rd, etc. are silently blocked
  }
}
```

### Fix 1: Async Gap Between Downloads

```typescript
// ✅ Add delay between each window.open call
async function downloadAll(fileUrls: string[]) {
  for (const url of fileUrls) {
    window.open(url, "_blank");
    await new Promise(resolve => setTimeout(resolve, 500));  // 500ms gap
  }
}
```

### Fix 2: Fetch + Blob URL (Popup-Blocker-Immune)

```typescript
// ✅ More reliable — no popup blocker involvement
async function downloadFile(url: string, filename: string) {
  const response = await fetch(url);
  const blob = await response.blob();
  const blobUrl = URL.createObjectURL(blob);

  const anchor = document.createElement("a");
  anchor.href = blobUrl;
  anchor.download = filename;
  anchor.click();

  URL.revokeObjectURL(blobUrl);  // cleanup
}

async function downloadAll(files: { url: string; name: string }[]) {
  for (const file of files) {
    await downloadFile(file.url, file.name);
    await new Promise(resolve => setTimeout(resolve, 200));  // shorter gap needed
  }
}
```

The `<a download>` approach doesn't trigger the popup blocker because it's a same-page navigation, not a new window.

### When `window.open` Is Required

For files hosted on a different origin (where `fetch` would be blocked by CORS), or for PDFs that need to open in a new tab, `window.open` may be unavoidable. In that case, the 500ms gap is the only option.

### Diagnosing the Issue

In Chrome DevTools:
- A blocked popup shows a small icon in the address bar ("Popup blocked")
- The browser console logs: `[blocked] Opening a URL that was denied by the popup policy`
- Or: `The window was not opened — popup blocker is active`

If only the first of N downloads completes, popup blocking is almost certainly the cause.

## Related Concepts

- [[wiki/concepts/native-track-blob-url]] — related browser URL object pattern for VTT tracks
- [[wiki/tech-patterns/react-vite-typescript]] — React frontend patterns

## Sources

- [[daily/2026-04-30.md]] — video-accessibility Download All button: sequential `window.open` calls triggered popup blocker; only first file downloaded; fix was 400ms async gap between calls
