Guides

Why your download link opens the file instead of downloading it — 3 causes

01

Illustration: a parcel being unpacked on the doorstep instead of handed over

You have a "Download report" button. The code is by the book:

<a href="https://xyz.supabase.co/storage/v1/object/public/reports/report.pdf" download="Report.pdf">
  Download
</a>

You click — and the PDF opens in a new tab. The browser ignored the name "Report.pdf". Same with an image: instead of a file in Downloads, a full-screen picture.

The download attribute is written correctly. It just comes with conditions people rarely mention. Here are three causes, most common first.

Cause 1. The file lives on another domain

It's almost always this. The download attribute only works for links on the same domain as the page — plus blob: and data: addresses. For everything else the browser silently ignores it. Chrome has done this since 2018, and the others do too.

Your site is on myshop.com, but the file sits in Supabase Storage, S3 or a CDN — that's a different domain. The browser reasons: the foreign server didn't ask for a download, so I'll decide myself. It knows how to display PDFs and images — so it displays them.

How to check. Look at the address in href. If its domain differs from the one in the address bar, even by a subdomain, this is it.

How to fix. Let the server itself say "this is a download". There's a response header for that:

Content-Disposition: attachment; filename="report.pdf"

The header beats the attribute and works from any domain. If it includes a file name, the browser uses that instead of whatever is in download.

In Supabase Storage the download option adds the header for you:

const { data } = await supabase.storage
  .from("reports")
  .createSignedUrl("report.pdf", 60, { download: "Report.pdf" });

A download parameter gets appended to the address, and the server sends the file as an attachment. getPublicUrl has the same option for public buckets. In S3, the ResponseContentDisposition parameter on a presigned URL does the same.

Cause 2. You fetch the file but never turn it into one

Sometimes you need to get the file with a token or generate it on the fly. So people write fetch — and nothing happens. The request succeeded, the data arrived, and Downloads is empty. fetch just brings bytes into memory. They won't become a file on disk by themselves.

How to fix. Make a temporary blob: link from the response — download always works for those:

const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const blob = await res.blob();
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "Report.pdf";
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);

Three catches here.

  • The other domain must allow the request. Otherwise fetch fails before any download. That's the familiar CORS error.
  • Don't release the link immediately. Calling revokeObjectURL on the same line as click() cancels the download in some browsers. A short pause fixes it.
  • Don't download big files this way. A blob holds the whole file in the tab's memory. A one-megabyte report is fine. A two-gigabyte video will freeze a phone. For big files, use the approach from cause 1.

Cause 3. An in-app browser

The link was opened from Telegram, Instagram or a mail app. That's not a real browser but an embedded window — a WebView. Many of them simply can't save files: they show the PDF or do nothing.

How to check. Open the same link in regular Safari or Chrome. If it downloads there, the embedded window is the culprit.

How to fix. Code can't help here. But you can tell the person: "If the file won't download, open the page in your browser." Embedded windows usually hide that option in the three-dot menu.

How to ask AI for it

A neural net will almost always write a bare <a download> and assure you it works. Give it context:

Weak promptmake a download report button
Strong promptMake a "Download report" button. The file is in a private Supabase Storage bucket called reports, and the site is on another domain, so the download attribute alone will not work. Get a link via createSignedUrl for 60 seconds with the download option and the name Report.pdf so the server sends Content-Disposition attachment. No fetch and no blob: files can be 200 MB.

How do I see which header the server sends?

Open DevTools, the Network tab, click the link and select the request for the file. In Response Headers, look for Content-Disposition. If it's missing or says inline, the browser is free to display the file.

Why do some files download while others open?

The browser opens what it can display itself: PDFs, images, video, text. It can't display an archive or a Word document — so it downloads those even without the attribute. That's why the bug is usually noticed only with PDFs.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →