How to Handle Browser File Downloads with an API
Learn when to use Content-Disposition or Fetch→Blob for browser API downloads, with filenames, CORS, streaming, errors, and runnable code.
Direct answer: For a normal browser download, have your API return the file with Content-Disposition: attachment and a safe filename. A link or navigation can then let the browser handle saving. Use fetch() followed by response.blob() when the app must add authorization headers, inspect the status or headers, or transform the bytes before offering the file. That route needs CORS for a cross-origin API, reads the response to completion, and requires object-URL cleanup.
The examples below cover both patterns, filename handling (including non-ASCII names), large files, errors, and production concerns.
1. Choose the download pattern
| Pattern | Use it when | Constraints |
|---|---|---|
Direct response with Content-Disposition: attachment |
A normal link or navigation is enough. | The server must emit the right headers. The browser controls the save UI and may adjust the name. |
Anchor with download |
The URL is same-origin, or is a blob: or data: URL, and you want to suggest a name. |
Cross-origin HTTP URLs are restricted; browser settings and server metadata can take precedence. |
| Fetch → Blob → object URL → download | You need request headers, response inspection, or a client-side transformation. | CORS must expose the response to JavaScript; blob() buffers the body to completion; revoke the object URL later. |
| Incremental stream or user-selected destination | The response is very large or the user must choose where bytes are written. | More code, browser support checks, and explicit user consent are required. |
2. Server response: make a browser download
Send a media type and an attachment disposition. RFC 6266 defines attachment as a request to save the response locally; actual browser UI can vary. MDN documents the same behavior and filename parameters.
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Length: 12345
Content-Disposition: attachment; filename="report.csv"
id,name
1,Ada
For international names, send an ASCII fallback plus RFC 5987 encoding in filename*:
Content-Disposition: attachment; filename="report.csv"; filename*=UTF-8''r%C3%A9sum%C3%A9.csv
Use a trusted, sanitized filename. Browsers can replace characters that are invalid on the user’s filesystem, so treat this as a suggestion rather than a guarantee. See MDN’s Content-Disposition reference and RFC 6266.
Minimal Node.js server (Express)
import express from "express";
import { readFile } from "node:fs/promises";
const app = express();
app.get("/download", async (req, res, next) => {
try {
const data = await readFile("./report.csv");
res.set({
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="report.csv"',
"Content-Length": data.length
});
res.send(data);
} catch (err) { next(err); }
});
app.listen(3000);
Minimal Python server (Flask)
from flask import Flask, send_file
app = Flask(__name__)
@app.get("/download")
def download():
return send_file(
"report.csv",
mimetype="text/csv",
as_attachment=True,
download_name="report.csv",
)
if __name__ == "__main__":
app.run(port=3000)
3. Simplest client: a link
If the endpoint already returns Content-Disposition: attachment, use an ordinary link:
<a href="/download">Download report</a>
You can add download for a same-origin URL to suggest a name:
<a href="/download" download="report.csv">Download report</a>
The download attribute applies to same-origin URLs and blob:/data: URLs. For cross-origin HTTP URLs, configure the server and rely on Content-Disposition. Server-provided filename metadata may take precedence; browser settings can also change the result. See MDN’s anchor documentation.
4. Fetch, inspect, and download a Blob
Use this when the request needs a bearer token, POST body, custom header, status handling, or a transformation.
async function downloadWithFetch(url, token, suggestedName = "download") {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const message = await response.text().catch(() => "");
throw new Error(`Download failed (${response.status}): ${message}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
link.remove();
// Keep the URL alive until the download has been initiated; then release it.
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
downloadWithFetch("https://api.example.test/report", "TOKEN", "report.csv")
.catch(console.error);
fetch() resolves even for HTTP errors, so check response.ok. response.blob() consumes the body to completion. Object URLs retain the underlying resource until revoked; do not revoke before the browser has had a chance to use the link. See MDN Fetch, Response.blob(), and blob: URL lifecycle guidance.
Read a server-suggested filename
function filenameFromDisposition(value) {
if (!value) return null;
const extended = value.match(/filename\*=(?:UTF-8'')?([^;]+)/i);
if (extended) return decodeURIComponent(extended[1].trim().replace(/^"|"$/g, ""));
const basic = value.match(/filename="?([^";]+)"?/i);
return basic ? basic[1].trim() : null;
}
async function downloadUsingHeader(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const name = filenameFromDisposition(response.headers.get("Content-Disposition")) || "download";
const objectUrl = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: objectUrl, download: name });
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
For a cross-origin response, JavaScript can read Content-Disposition only when the server exposes it with Access-Control-Expose-Headers.
5. CORS configuration for cross-origin APIs
Cross-origin Fetch can send a request while still hiding the response from JavaScript. Allow the requesting origin and expose any headers your code reads:
Access-Control-Allow-Origin: https://app.example
Access-Control-Expose-Headers: Content-Disposition, Content-Length, Content-Type
Do not use mode: "no-cors" as a workaround: it produces an opaque response whose body and headers are inaccessible. Calling blob() on an opaque response yields a zero-size Blob with an empty type. Configure CORS on the API (including preflight handling for authorization and non-simple methods) and test the deployed origin.
6. Large files and streaming
Blob downloads hold the complete body before the download link is created. For large responses, process response.body incrementally:
const response = await fetch("/large-export");
if (!response.ok || !response.body) throw new Error("Cannot read download");
const reader = response.body.getReader();
let received = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
received += value.byteLength;
// Send each Uint8Array to an application-specific sink.
}
console.log(`Received ${received} bytes`);
A user-selected destination such as the File System Access API can avoid assembling a giant Blob, but it requires explicit user consent and feature detection. The platform documentation covers the API surface; verify support for your target browsers before making it a requirement.
7. POST downloads and authenticated requests
const response = await fetch("/exports", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer TOKEN" },
body: JSON.stringify({ filters: { status: "paid" } })
});
if (!response.ok) throw new Error(`Export failed: ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "paid-orders.csv"; a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
Never put bearer tokens in a publicly shareable download URL. If a direct link is required, use a short-lived, scoped server-issued URL and keep authorization checks on the server.
8. cURL, Python, and Node.js clients
cURL
curl -fL -H "Authorization: Bearer $TOKEN" -o report.csv https://api.example.test/report
Python
import requests
with requests.get(
"https://api.example.test/report",
headers={"Authorization": "Bearer " + TOKEN},
stream=True,
timeout=90,
) as response:
response.raise_for_status()
with open("report.csv", "wb") as output:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
output.write(chunk)
Node.js
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
const response = await fetch("https://api.example.test/report", {
headers: { Authorization: `Bearer ${process.env.TOKEN}` }
});
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
await pipeline(response.body, createWriteStream("report.csv"));
9. Or skip the browser setup
For a website screenshot endpoint, ScreenshotNeo returns the image or PDF directly from one GET request. The API accepts the URL and returns PNG, JPEG, WebP, or PDF; its docs list the capture options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners, newsletter popups, and chat widgets are removed before the shot.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers state the page verdict and billing result.
- An MCP server lets Claude, Cursor, and other MCP clients use
take_screenshot,get_page_info, andcapture_pdf. - Free includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and get 1,000 screenshots a month without a card.
10. Troubleshooting checklist
| Symptom | Cause | Fix |
|---|---|---|
| Browser opens JSON instead of saving | Missing or incorrect disposition. | Return Content-Disposition: attachment and a valid filename. |
| Filename is always “download” | No filename metadata, cross-origin header not exposed, or browser sanitization. | Send both filename and filename*; expose Content-Disposition; treat the name as a suggestion. |
| Fetch throws a CORS error | Origin or preflight is not allowed. | Allow the exact app origin and required methods/headers; expose headers you read. |
| Blob is empty | Opaque no-cors response or an already-consumed body. |
Remove no-cors, configure CORS, and consume the body once. |
| HTTP 404/500 handled as a successful download | Fetch resolves on HTTP errors. | Check response.ok or status before reading the body. |
| Download fails after navigation | Authentication was supplied only in JavaScript headers. | Use Fetch→Blob, a server-side proxy, or a short-lived authorized URL. |
| Memory grows after many downloads | Object URLs were never released. | Call URL.revokeObjectURL() after the user no longer needs each URL. |
| Large export freezes the tab | blob() buffers the entire response. |
Use incremental stream processing or a user-selected file destination. |
11. Performance, reliability, and cost notes
- Performance: direct attachment avoids client-side buffering. Blob downloads add memory proportional to the response; streaming reduces that pressure.
- Reliability: set server timeouts, return accurate status codes, support resumable or retryable jobs for long exports, and verify content type before saving. Retries must account for non-idempotent export creation.
- Security: validate authorization on every request, sanitize filenames, avoid reflecting untrusted header values, and keep tokens out of URLs and logs.
- Cost: browser APIs themselves have no per-download fee. Your API and storage/network provider may charge for generation, egress, or storage; measure response size and retry behavior in your own deployment.
- Observability: log request IDs, status, bytes sent, duration, and cancellation separately from application errors. Do not log access tokens.
12. FAQ
Can I force a download with JavaScript alone?
You can trigger an anchor click for same-origin, Blob, or data URLs. For a cross-origin URL, the server’s response headers and CORS policy still control what the browser exposes.
Does Content-Disposition guarantee the exact filename?
No. It supplies metadata; browsers and operating systems may sanitize or alter it.
Should every download use Fetch and Blob?
No. A normal link plus an attachment response is simpler and avoids client buffering. Use Fetch when you need request or response control.
How do I download a PDF generated by an API?
Return Content-Type: application/pdf with attachment disposition, or fetch it as a Blob and use an object URL. The same CORS and lifecycle rules apply.
What happens when the user cancels?
Abort the Fetch with an AbortController, stop server work where possible, and release any object URL after the link is no longer used.


