A Mac user clicked Export in an app I maintain and got this instead of a spreadsheet:

WebKitBlobResource error 1

The pattern that broke

The download code was the pattern you find everywhere: fetch the file, turn it into a blob, point an invisible link at it, click the link, tidy up. In outline:

async function download(url, name) {
  const res = await fetch(url);
  const blob = await res.blob();
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = name;
  a.click();
  URL.revokeObjectURL(a.href);
}

The app had four hand-rolled copies of it.

In WebKit it fails in three steps. The click that started everything counts as user activation, and WebKit drops that activation across the awaits. By the time a.click() runs, the browser no longer treats it as something the user did, so it refuses the download and navigates to the blob URL instead. Then the last line revokes that URL, and the navigation lands on something that no longer exists. Error 1.

The server could already do it

Every export route in the app already answered with:

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

That header is the server telling the browser to save the response and not show it. With it in place, a plain navigation to the URL downloads the file natively, in every browser, with no blob, no invisible link and nothing to revoke:

function download(url) {
  window.location.href = url;
}

If the server sends Content-Disposition: attachment, the browser already knows how to download the file. The JavaScript was doing a job that was already done.

One helper replaced the four copies. The day after, a different download problem turned up on the same routes, and that one was Cloudflare caching the exports.

The case that keeps its blob

One download in the app is the result of a POST: the page sends a body and the response is the file. A navigation can’t carry a body, so that one keeps the fetch-and-blob pattern. It is the exception, and it is now the only place in the app where that code exists.

If your downloads are GET requests to routes you control, check what the response headers already say before writing any of this. Mine had been saying it all along.