95 lines
3.8 KiB
Markdown
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
|