GuidesFILES
Download a file from a URL in JavaScript
<a href download> saves a file under your chosen name only for same-origin URLs; for cross-origin URLs the HTML standard lets browsers ignore the name. On 28 October 2026 a cross-origin CSV link saved under the URL's name in Chromium 153 and Firefox 155 and opened in WebKit 26.6, and a cross-origin PNG opened in all 3. Fetching the file into a blob and clicking an object URL works everywhere but needs CORS, which the free cors.dev proxy adds for public files up to 6 MiB (6,291,456 bytes), with no signup and about 600 requests a minute per IP.
Why the download attribute fails on other origins
The download attribute on an a element asks the browser to save the linked file and suggests a name. MDN's compatibility data lists support since Chrome 14, Firefox 20 and Safari 10.1, and for a same-origin file nothing else is needed: <a href="/files/report.csv" download="report-2026.csv"> saves report-2026.csv.
Cross-origin links are different. The HTML standard treats them as untrusted and notes: "In cross-origin situations, the download attribute has to be combined with the Content-Disposition HTTP header, specifically with the attachment disposition type, to avoid the user being warned of possibly nefarious activity." For a same-origin link the attribute's name wins unless the server sends a Content-Disposition file name; for a cross-origin link the browser uses the Content-Disposition file name when the server sends attachment, and otherwise picks a name itself, usually from the URL, or opens the file.
What each browser did with a cross-origin link
The test on 28 October 2026 used Chromium 153, Firefox 155 and WebKit 26.6 in Playwright 1.63 (WebKit is Playwright's Linux build, so Safari on macOS may differ). The page ran on http://127.0.0.1:8001, the files on http://127.0.0.1:8002, and every link carried a name such as download="my-report.csv".
| Link | Chromium 153 and Firefox 155 | WebKit 26.6 |
|---|---|---|
| Same-origin CSV | Saved as my-report.csv | Same |
| Cross-origin CSV | Saved as report.csv | Opened the file |
| Cross-origin CSV, Content-Disposition attachment | Saved as server-name.csv | Opened the file |
| Cross-origin PNG | Opened the image | Same |
| Cross-origin PNG, Content-Disposition attachment | Saved as server-logo.png | Opened the image |
| Cross-origin PDF, headless | Saved as doc.pdf | Same |
Chromium and Firefox never used the attribute's name on another origin: they took the URL's name or the server's Content-Disposition name. WebKit opened every cross-origin file except the PDF in the tab and logged The download attribute on anchor was ignored because its href URL has a different security origin. The PDF saved everywhere only because headless browsers have no PDF viewer; a desktop browser may show it in a tab instead.
Download with fetch, a blob and an object URL
To save any file under your own name, fetch it, read it with response.blob(), turn it into an object URL with URL.createObjectURL(), point a temporary a element with a download attribute at that URL and call click(). An object URL belongs to your page's origin, so the attribute's name always wins.
async function downloadFile(url, filename) {
const target = new URL(url, location.href);
const sameOrigin = target.origin === location.origin;
const response = await fetch(sameOrigin ? target : `https://proxy.cors.dev/${target.href}`);
if (!response.ok) {
const reason = response.headers.get('X-Cors-Error') ?? `HTTP ${response.status}`;
throw new Error(`Download failed: ${reason}`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
// Release the blob once the browser has taken over the download
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
await downloadFile('https://www.irs.gov/pub/irs-pdf/fw9.pdf', 'w9-form.pdf');- Pick the route
same-origin URLs are fetched directly, everything else through
https://proxy.cors.dev/followed by the full file URL. - Fail loudly
a response that isn't OK throws
Download failed:plus theX-Cors-Errorvalue or the HTTP status. - Save the blob
the body becomes an object URL behind a temporary link with your file name, which is clicked and removed.
- Clean up
URL.revokeObjectURL()frees the blob after 60 seconds. Revoking right afterclick()still saved the file in all 3 engines, so the delay is only a safety margin.
On 28 October 2026 the helper saved same-origin.csv (16 bytes), unemployment.csv from FRED (14,211 bytes), w9-form.pdf (140,815 bytes) and google-logo.png (13,504 bytes) under those names in all 3 engines, each in under a second. A 10 MiB file threw Download failed: response_too_large.
Files without CORS headers: route them through a proxy
fetch() on another origin needs the file host to send Access-Control-Allow-Origin. Without it the promise rejects with TypeError (Failed to fetch in Chromium, NetworkError when attempting to fetch resource. in Firefox, Load failed in WebKit), as covered in TypeError: Failed to fetch. Some hosts send it: the arXiv PDF https://arxiv.org/pdf/1706.03762 (2,215,244 bytes) answers with Access-Control-Allow-Origin: * and can be fetched directly.
Most file hosts don't. None of these sent the header on 28 October 2026: the FRED unemployment series https://fred.stlouisfed.org/graph/fredgraph.csv?id=UNRATE, the IRS Form W-9 https://www.irs.gov/pub/irs-pdf/fw9.pdf, Google's logo PNG and the 1 MiB and 10 MiB test files on proof.ovh.net. Prefixing each URL with https://proxy.cors.dev/ added it:
| File | Size | Through the proxy |
|---|---|---|
| FRED unemployment CSV | 14,211 bytes | 200 application/csv |
| IRS Form W-9 PDF | 140,815 bytes | 200 application/pdf |
| Google logo PNG | 13,504 bytes | 200 image/png |
| 1 MiB test file | 1,048,576 bytes | 200 application/octet-stream |
| 10 MiB test file | 10,485,760 bytes | 502 response_too_large |
The 10 MiB file came back as 502 with X-Cors-Error: response_too_large and a JSON body with "error":"response_too_large", the message The upstream response exceeds the response size limit., a docs link and a requestId. Some hosts refuse proxy traffic outright: a W3C sample PDF answered 403 through the proxy.
The free proxy relays binary types such as PDF, images and application/octet-stream only to requests that carry an Origin header. Browsers send one on every cross-origin fetch(), so page code works, while the same URL in curl without an Origin header answers 502 with X-Cors-Error: unsupported_response.
Large files and other limits
The blob method holds the whole file in memory before the save starts, so keep it for small and medium files. The proxy adds its own limits:
- Free proxy: no signup or key,
GETandHEADonly, about 600 requests a minute per IP, responses up to 6 MiB (6,291,456 bytes), a 30-second upstream deadline and up to 20 redirects. - cors.dev Pro: $5 a month for 500,000 requests, with a 7-day trial of 1,000 requests and no card. It forwards
Authorizationand custom headers for files behind a token and allows 3,000 requests a minute per account and 100 in flight; the 6 MiB and 30-second limits stay the same. - Files over 6 MiB: link to them directly, ideally from a server that sends
Content-Disposition: attachment, serve them from your own origin, or add CORS rules to the bucket as in AWS S3 CORS.
Good questions.
Why does my download link open the file instead of saving it?
Browsers treat a download link to another origin as untrusted. Without Content-Disposition: attachment from the server, Chromium 153 and Firefox 155 opened cross-origin images in the tab, and WebKit 26.6 opened cross-origin CSV and PNG files even with that header. Fetching the file into a blob and clicking an object URL saves it in all 3.
How do I rename a file downloaded from another domain?
The download attribute's name only applies to same-origin URLs and object URLs. Fetch the file, call response.blob(), create an object URL with URL.createObjectURL() and click a temporary <a download="new-name.csv"> pointing at it. The saved file then used the new name in every browser tested on 28 October 2026.
How do I keep the file name the server suggests?
Read it from the Content-Disposition response header and pass it to your download code. Through the proxy, upstream headers are readable from JavaScript, so response.headers.get('Content-Disposition') returned FRED's attachment; filename="UNRATE.csv". On a direct request to a CORS-enabled host, the header is only readable when the host lists it in Access-Control-Expose-Headers.
Why does the proxy answer 502 unsupported_response when I test a PDF with curl?
The free proxy only relays binary types such as PDF, images and application/octet-stream to requests that send an Origin header. Browsers add Origin to every cross-origin fetch(), so page code gets the file, while curl sends none. Add -H "Origin: https://example.com" to see what the browser gets.
Is the object URL method safe for large files?
It works, but the whole file sits in memory as a blob until the save starts, and through cors.dev the response is capped at 6 MiB on both the free tier and Pro. For bigger files, link to them directly from a host that sends Content-Disposition: attachment, so the browser streams the download to disk itself.