obsidian/wiki/concepts/browser-sequential-download-blocking.md
2026-05-01 09:38:54 +01:00

3.8 KiB

title aliases tags sources created updated
Browser Sequential Download Blocking — window.open Needs 400ms Gap
browser-download-blocking
sequential-download
window-open-popup-blocker
browser
javascript
frontend
download
popup-blocker
gotcha
daily/2026-04-30.md
2026-04-30 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

// ❌ 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

// ✅ 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)

// ✅ 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.

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