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

95 lines
3.8 KiB
Markdown

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