DEV Community

Cover image for The download button worked everywhere except iPhone
Jamie Phillis
Jamie Phillis

Posted on

The download button worked everywhere except iPhone

I launched my first real site yesterday, and it really has been a labour of love.. Something I have spent an ungodly amount of hours building out 387 free browser tools, everything runs client side, nothing uploads. An hour after launch someone built a RAMS document on their phone, tapped download, and nothing happened.

No error. No console warning. No dialog. A button that just did nothing, on one of my flagship features, on an iPhone, which is half the phones on every building site in where I live.

Here is the four line pattern that was behind it. You have probably shipped it too, because every tutorial on the internet teaches it:

const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
Enter fullscreen mode Exit fullscreen mode

Looks responsible, right? Clean up your object URLs, the docs say so. It
works in Chrome, it works in Firefox, it works in desktop Safari. It works in every test you will ever run on your own machine.

## What iPhone Safari actually does!

When you tap a download link on iOS, Safari does not download the file. It shows a confirmation dialog and asks if you want to. The blob only gets read AFTER you tap Download in that dialog.

Chrome effectively captures the bytes at click time, so you can revoke the URL a millisecond later and nothing breaks. Safari does not. By the time a person has read the dialog and tapped the button, a URL revoked on a zero millisecond timer has been dead for half a second. Safari goes to fetch arevoked URL, gets nothing, and shows you nothing. Not a failed download. Nothing.

So the bug is a race between your cleanup code and a human finger, and your cleanup always wins.

## The part that was actually embarrassing

I grepped the codebase to fix it and found the same four lines pasted in
TWELVE places, with FOUR different revoke timings. Two revoked synchronously, which is even worse than the zero timer. Two at 0ms. Four at one second, which loses whenever a human hesitates on the dialog. Four at ten seconds, which mostly works by accident.

One of the ten second ones even had a comment saying Safari has been known to cancel the download if the URL dies in the same tick as the click. Somebody had met this bug before, fixed it in ONE spot, and the knowledge never travelled to the other eleven.

If there is one spot there is many. That is the actual lesson. The fix is
not a longer timeout, it is making it impossible to write a new timing:

export function saveBlobAs(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
Enter fullscreen mode Exit fullscreen mode

Sixty seconds covers reading the dialog, a slow tap, and a slow fetch. The memory cost is a rounding error, these files are kilobytes, and navigating away frees every object URL anyway. All twelve call sites now import this one function, and nothing else in the codebase is allowed to hand a file to the browser.

## The twist that made it worth writing up

Two of the twelve copies were in files my greps had NEVER been able to see. Both had a control character sanitiser written with literal raw bytes inside the regex, an actual NUL sitting in the source code, so grep classified both files as binary and silently skipped them in every search since the day they were written. The bug audit is what finally noticed.

And my first fix for THAT failed too, because I patched the bytes through a shell heredoc, and the shell ate the backslashes and swapped raw bytes for identical raw bytes. My own assertion caught the fix doing nothing. The second attempt built the replacement from numeric byte values instead. If your escape sequences pass through a shell on the way to a file, they are not your escape sequences any more.

## Check your own codebase

grep -rn "revokeObjectURL" src/
Enter fullscreen mode Exit fullscreen mode

If any hit is within a few lines of a click() and the timer is under ten
seconds, your iPhone users have a button that does nothing, and not one of them has ever told you, because there is nothing to report. It does not error. It just does not happen.

Found by the first real user an hour after launch. Fixed and verified on the same phone the same evening. The site is iluvfreetools.com if you want to see the pattern live, every tool on it saves through that one function now.

Part time commercial manager, part time developer. Full time problem
solver. I spent twenty years in steel fabrication before I started shipping software, and I built this site because I was sick of every free tool demanding my email address.

Top comments (0)