For about a year, the save button in my collage editor did nothing on phones. Not "threw an error." Not "downloaded a corrupted file." Nothing at all. You tapped it, the button got its little active state, and then the app just sat there.
Nobody reported it. That's the part that still bothers me. A button that crashes gets a bug report; a button that does nothing gets interpreted as user error. People assumed they'd tapped wrong, tapped again, then left.
The code that looked fine
The editor is vanilla JS drawing to a <canvas>. Everything happens client-side — the images never leave the device, which is the whole point of the thing. So exporting was the textbook approach:
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'collage.png';
a.click();
This works in every desktop browser. It works in Chrome DevTools' device emulation, which is how it survived so long. It works in a mobile browser often enough that you don't immediately suspect it.
It does not work in an Android WebView, and it does not work in iOS Safari.
The download attribute is a hint, and both of those environments are free to ignore it. An Android WebView has no download manager attached unless the host app wires one up, so the navigation to a data: URL is simply dropped. iOS Safari has historically refused download on data: and blob: URLs from a synthetic click. Neither throws. Neither logs. There is no rejected promise to catch, no event to listen for. The failure mode is a return value of undefined from a function that had one job.
That's what makes this class of bug expensive: you cannot detect it from the JS side. There's no if (downloadWorked). You find it by holding a phone.
Fixing Android: stop pretending it's a browser
The mobile apps are the same editor wrapped in Capacitor, so on Android I had a native layer available and used it. The web code hands the base64 payload to a plugin, and the plugin writes the bytes to shared storage with the platform's own file APIs.
const dataUrl = canvas.toDataURL('image/png');
const base64 = dataUrl.split(',')[1];
await Filesystem.writeFile({
path: `collage-${Date.now()}.png`,
data: base64,
directory: Directory.Documents,
});
Unremarkable, and that's the point. On Android the honest fix was to admit I wasn't in a browser and use the host platform. The user gets a real file in a real folder, which is what they wanted when they tapped save.
Fixing iOS: the Web Share API, and the constraint it doesn't document
iOS has no equivalent escape hatch I was willing to take, so the export goes through the Web Share API instead:
const file = dataUrlToFile(dataUrl, 'collage.png');
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: 'Collage' });
}
Note canShare({ files }) rather than a bare check for navigator.share. Sharing text is widely supported; sharing files is a separate capability, and the feature-detect has to include the payload or it lies to you.
The part that cost me an evening is not in that snippet. navigator.share() requires transient user activation — it must be called during the gesture that triggered it. And that activation is consumed by await. If you write the natural-looking version:
// broken on iOS
button.addEventListener('click', async () => {
const blob = await new Promise(res => canvas.toBlob(res));
const file = new File([blob], 'collage.png', { type: 'image/png' });
await navigator.share({ files: [file] }); // silently dismissed
});
...the share sheet either never appears or flashes and dies. Same signature as the original bug: no error, no console output. The await yielded to the event loop, the activation window closed, and iOS declined without comment.
So the base64-to-File conversion has to be synchronous. canvas.toDataURL() is synchronous, atob() is synchronous, and the File constructor is synchronous — the whole chain can run inside the handler with no yield point:
function dataUrlToFile(dataUrl, filename) {
const [header, payload] = dataUrl.split(',');
const mime = header.match(/:(.*?);/)[1];
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], filename, { type: mime });
}
fetch(dataUrl).then(r => r.blob()) is the prettier version of this function and it is unusable here, because it's async. Same for canvas.toBlob(). The ugly charCodeAt loop exists specifically to stay inside the gesture. For a few megapixels it costs a handful of milliseconds, which is a fine trade for a feature that otherwise doesn't exist.
One caveat worth stating plainly: navigator.share() returning without rejecting does not mean a file was saved. It means the sheet was presented. You cannot tell whether the user picked "Save Image," sent it to a chat, or cancelled — the promise resolves the same way. Some visibility is simply not available to you.
The part I'm not happy about
On iOS the user taps a button labelled save and gets a share sheet, where "Save Image" is one option among Messages, Mail, AirDrop and a dozen apps. That is not a download and does not look like one. It's an extra decision at exactly the moment the user thought they were done.
I shipped it because a share sheet is enormously better than nothing, and because the alternative on iOS was a long-press-to-save instruction nobody reads. But I want to be clear that it's a compromise, not a solution. If you're evaluating this approach: the ceiling is "acceptable," not "good."
A second silent failure, same shape
While I was in there, I found a bug with an identical personality in the analytics setup. Google Consent Mode was initialised with defaults denied — correct and required — but the cookie banner that could flip those defaults only rendered for EU visitors, because that's who the compliance requirement targeted.
Read those two facts together: every visitor outside the EU started denied and was never shown any way to consent. They were permanently invisible in GA4. The site looked close to dead in the reports while it was in fact getting traffic.
gtag's consent defaults take a region parameter, and the fix is to scope the denial to the places where the banner actually exists:
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
region: [
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU',
'IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES',
'SE','IS','LI','NO','GB','CH'
],
});
With a region array, the directive applies only to those countries; visitors elsewhere fall through to whatever your unscoped default is. You still need a real consent flow for the listed regions — this isn't a way to opt out of the banner, it's a way to stop applying a banner's defaults where the banner never runs.
What actually generalises
Both bugs were invisible to every tool I had pointed at the project. No exception, no failed request, no red line anywhere. They were only visible as an absence: a file that never appeared, a country that never showed up in a report.
So the lesson I took isn't about download attributes or consent strings specifically. It's that anything you hand off to the platform — a download, a share sheet, a permission prompt, a consent signal — can be declined in silence, and your code will happily report success. Those handoffs are where I now check the outcome on a real device rather than trusting the call site.
The editor in question is freecollageimage.com, if the context helps. The bugs are the transferable part.
Top comments (0)