Build1 publisher3 min readPublished
A save button that did nothing for a year, and the zero bug reports that hid it
The HTML download attribute is a hint browsers may ignore. On Android WebView and iOS Safari it was ignored silently, and DevTools emulation never noticed.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- For about a year, the save button in the author's collage editor did nothing on phones: no thrown error, no corrupted file, nothing. The button got its active state and the app sat there.
- Nobody reported the bug. The author's reading: a button that crashes gets a bug report; a button that does nothing gets interpreted as user error, so people assumed they had tapped wrong, tapped again, then left.
- The editor is vanilla JS drawing to a <canvas>, everything happens client-side, and the images never leave the device, which is the point of the tool.
- The original export used canvas.toDataURL('image/png'), then created an anchor element, set a.href to the data URL, set a.download = 'collage.png', and called a.click().
- That approach works in every desktop browser and works in Chrome DevTools device emulation, which is how the bug survived so long, and it works in a mobile browser often enough not to raise immediate suspicion.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
For roughly a year, the save button in a vanilla JS collage editor did nothing on phones: no error, no corrupt file, just a tap, an active state, and silence [1]. According to the developer's writeup on dev.to, nobody filed a bug in that time, because a button that crashes gets reported and a button that does nothing gets read as user error [2][22].
The export was the textbook version. Draw to a canvas, call `canvas.toDataURL('image/png')`, create an anchor, set `href` and `download`, click it [4]. The editor is entirely client-side, images never leaving the device, which is the product's whole premise [3]. That code works in every desktop browser and it works in Chrome DevTools device emulation, which is precisely why it survived so long [5]. It does not work in an Android WebView, and it does not work in iOS Safari [6].
The mechanism is unglamorous: `download` is a hint, and both environments are free to ignore it [7]. An Android WebView has no download manager attached unless the host app wires one up, so the navigation to a `data:` URL is dropped [8]. iOS Safari has historically refused `download` on `data:` and `blob:` URLs from a synthetic click [9]. Neither throws, neither logs, and there is no rejected promise to catch or event to listen for [10]. There is no `if (downloadWorked)` to write, so the bug is not detectable from the JS side at all; you find it by holding a phone [11].
The fixes are platform-specific because the capability is. The mobile builds are the same editor wrapped in Capacitor, so on Android the developer handed the base64 payload to a plugin and wrote the bytes with the platform's own file APIs, via `Filesystem.writeFile` into `Directory.Documents` [12][13]. On iOS there was no equivalent escape hatch worth taking, so export goes through the Web Share API instead [14].
That is where the second silent failure lives. The feature detect has to be `navigator.canShare({ files: [file] })`, not a bare check for `navigator.share`, because sharing text is widely supported and sharing files is a separate capability; a detect without the payload lies [15]. Worse, `navigator.share()` requires transient user activation and must be called during the gesture that triggered it, and that activation is consumed by `await` [16]. The natural-looking version, awaiting `canvas.toBlob` before sharing, produces a share sheet that never appears or flashes and dies, with no error and no console output [17]. Same signature as the original bug.
So the data-URL-to-`File` conversion has to be synchronous: `toDataURL()`, `atob()`, and the `File` constructor all are, so the chain can run inside the handler with no yield point [18]. The ugly `charCodeAt` loop over a `Uint8Array` exists only to stay inside the gesture [19]. The prettier `fetch(dataUrl).then(r => r.blob())` is unusable because it is async, as is `canvas.toBlob()`; the loop costs a handful of milliseconds for a few megapixels [20]. Finding that cost the developer an evening [21].
Worth watching in your own stack: any success path that has no observable signal. Emulation reproduces viewport, not download policy or activation rules, so if the only proof that an export worked is a user telling you, assume it does not.