3.8 KiB
| title | aliases | tags | sources | created | updated | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Browser Sequential Download Blocking — window.open Needs 400ms Gap |
|
|
|
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.createObjectURLis 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.
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.opencalls triggered popup blocker; only first file downloaded; fix was 400ms async gap between calls