[{"data":1,"prerenderedAt":520},["ShallowReactive",2],{"blog-\u002Fblog\u002Fthe-button-that-did-nothing":3},{"id":4,"title":5,"body":6,"canonical":508,"date":509,"description":510,"draft":511,"duration":508,"extension":512,"lang":508,"meta":513,"navigation":514,"path":515,"place":508,"placeLink":508,"readingTime":516,"seo":517,"stem":518,"__hash__":519},"blog\u002Fblog\u002Fthe-button-that-did-nothing.md","The button that did nothing",{"type":7,"value":8,"toc":498},"minimark",[9,14,22,25,28,33,54,62,65,69,72,91,143,146,172,175,301,311,315,326,377,389,393,399,402,406,409,415,422,460,463,469,473,476,479,483,486,494],[10,11,13],"h3",{"id":12},"most-of-my-users-lived-on-a-phone-i-built-the-whole-thing-on-a-laptop-heres-what-that-cost-and-the-rule-i-keep-now","Most of my users lived on a phone. I built the whole thing on a laptop. Here's what that cost, and the rule I keep now.",[15,16,17],"p",{},[18,19],"img",{"alt":20,"src":21},"A download button pressed over and over, each tap rippling out into nothing, with a counter reading times twenty.","\u002Fimages\u002Fblog\u002Fthe-button-that-did-nothing\u002Fcover.svg",[15,23,24],{},"You've met a dead button. The crosswalk one that isn't wired to anything. The elevator \"close door\" that's decoration. The one you press, and when nothing happens you press it again, harder, because surely it heard you the first time. There's a particular small humiliation in it, standing there jabbing a button while the world declines to respond.",[15,26,27],{},"I built one of those. Not for me. For most of the people who showed up.",[29,30,32],"h2",{"id":31},"the-recording-i-couldnt-stop-watching","The recording I couldn't stop watching",[15,34,35,36,43,44,48,49,53],{},"We shipped a little thing on ",[37,38,42],"a",{"href":39,"rel":40},"https:\u002F\u002Fwww.bethelflow.com\u002F",[41],"nofollow","BethelFlow"," called ",[45,46,47],"em",{},"My Pastor Once Said",". You type a line your pastor never stopped saying, add a photo, and it makes a shareable card. Simple, joyful, exactly the kind of thing that's supposed to just work. I wrote about ",[37,50,52],{"href":51},"\u002Fblog\u002Fmy-pastor-once-said","designing the whole campaign"," separately. This post is about the part that broke.",[15,55,56,57,61],{},"Then I opened a session replay one morning. An iPhone. Mobile Safari. Someone had made their card. It was right there on the screen, finished, theirs. Then they tapped ",[58,59,60],"strong",{},"Download",". Nothing. They tapped it again. Again. I counted later: twenty times over eight minutes. Then they tried WhatsApp twice, then they left. No card ever reached them.",[15,63,64],{},"I watched it the way you watch someone wave at a sliding door that isn't going to open. And the worst part is that on my machine, a desktop running Chrome, I had pressed that exact button a hundred times and it worked every single time. It worked in the only place I ever tested it.",[29,66,68],{"id":67},"three-different-silences","Three different silences",[15,70,71],{},"When I finally pulled the button apart, it wasn't broken in one way. It was quietly broken in three, and each one was invisible from where I sat.",[15,73,74,77,78,82,83,86,87,90],{},[58,75,76],{},"One: iOS doesn't honour the download trick."," The pattern every tutorial teaches is simple: make an anchor, set ",[79,80,81],"code",{},"download",", click it in code. Safari on iOS simply ignores it. And my click landed ",[45,84,85],{},"after"," an ",[79,88,89],{},"await"," while the image rendered, so the user's tap was already spent by the time the download tried to fire.",[92,93,98],"pre",{"className":94,"code":95,"language":96,"meta":97,"style":97},"language-ts shiki shiki-themes github-light github-dark","const dataUrl = await renderCardDataUrl(cardRef.current) \u002F\u002F the tap is gone by here\nsaveDataUrl(dataUrl, filename)                           \u002F\u002F iOS: politely does nothing\n","ts","",[79,99,100,131],{"__ignoreMap":97},[101,102,105,109,113,116,119,123,127],"span",{"class":103,"line":104},"line",1,[101,106,108],{"class":107},"szBVR","const",[101,110,112],{"class":111},"sj4cs"," dataUrl",[101,114,115],{"class":107}," =",[101,117,118],{"class":107}," await",[101,120,122],{"class":121},"sScJk"," renderCardDataUrl",[101,124,126],{"class":125},"sVt8B","(cardRef.current) ",[101,128,130],{"class":129},"sJ8bj","\u002F\u002F the tap is gone by here\n",[101,132,134,137,140],{"class":103,"line":133},2,[101,135,136],{"class":121},"saveDataUrl",[101,138,139],{"class":125},"(dataUrl, filename)                           ",[101,141,142],{"class":129},"\u002F\u002F iOS: politely does nothing\n",[15,144,145],{},"On desktop this saves a file. On a phone it's a dead button. Same code, opposite outcome, and I only ever stood on the side where it worked.",[15,147,148,151,152,155,156,159,160,163,164,167,168,171],{},[58,149,150],{},"Two: the failure underneath, that hit everyone."," Our security headers set ",[79,153,154],{},"connect-src 'self' https: wss:",". Reasonable. But building the image meant turning a data URL into a blob, and I'd done it the lazy way, ",[79,157,158],{},"fetch(dataUrl)",". ",[79,161,162],{},"fetch"," answers to ",[79,165,166],{},"connect-src",". So the browser blocked me from fetching ",[45,169,170],{},"my own image",", the call threw, and the file was never built. Not just on iOS. Everywhere. iOS just failed more loudly because it had no fallback to limp to.",[15,173,174],{},"The fix was to stop asking the network for something I already held in my hand, and decode the base64 myself:",[92,176,178],{"className":94,"code":177,"language":96,"meta":97,"style":97},"\u002F\u002F was: const blob = await fetch(dataUrl).then(r => r.blob())  \u002F\u002F CSP blocks this\nconst [head, body] = dataUrl.split(\",\", 2)\nconst bytes = Uint8Array.from(atob(body), c => c.charCodeAt(0))\nconst blob = new Blob([bytes], { type: \"image\u002Fpng\" })\n",[79,179,180,188,231,276],{"__ignoreMap":97},[101,181,182,185],{"class":103,"line":104},[101,183,184],{"class":129},"\u002F\u002F was: const blob = await fetch(dataUrl).then(r => r.blob())",[101,186,187],{"class":129},"  \u002F\u002F CSP blocks this\n",[101,189,190,192,195,198,201,204,207,210,213,216,219,223,225,228],{"class":103,"line":133},[101,191,108],{"class":107},[101,193,194],{"class":125}," [",[101,196,197],{"class":111},"head",[101,199,200],{"class":125},", ",[101,202,203],{"class":111},"body",[101,205,206],{"class":125},"] ",[101,208,209],{"class":107},"=",[101,211,212],{"class":125}," dataUrl.",[101,214,215],{"class":121},"split",[101,217,218],{"class":125},"(",[101,220,222],{"class":221},"sZZnC","\",\"",[101,224,200],{"class":125},[101,226,227],{"class":111},"2",[101,229,230],{"class":125},")\n",[101,232,234,236,239,241,244,247,249,252,255,259,262,265,268,270,273],{"class":103,"line":233},3,[101,235,108],{"class":107},[101,237,238],{"class":111}," bytes",[101,240,115],{"class":107},[101,242,243],{"class":125}," Uint8Array.",[101,245,246],{"class":121},"from",[101,248,218],{"class":125},[101,250,251],{"class":121},"atob",[101,253,254],{"class":125},"(body), ",[101,256,258],{"class":257},"s4XuR","c",[101,260,261],{"class":107}," =>",[101,263,264],{"class":125}," c.",[101,266,267],{"class":121},"charCodeAt",[101,269,218],{"class":125},[101,271,272],{"class":111},"0",[101,274,275],{"class":125},"))\n",[101,277,279,281,284,286,289,292,295,298],{"class":103,"line":278},4,[101,280,108],{"class":107},[101,282,283],{"class":111}," blob",[101,285,115],{"class":107},[101,287,288],{"class":107}," new",[101,290,291],{"class":121}," Blob",[101,293,294],{"class":125},"([bytes], { type: ",[101,296,297],{"class":221},"\"image\u002Fpng\"",[101,299,300],{"class":125}," })\n",[15,302,303,306,307,310],{},[58,304,305],{},"Three: the button, hammered."," To feel snappy, I'd pre-warmed the share on hover. But on every failure it reset and tried again on the ",[45,308,309],{},"next"," pointer event. So a person poking a dead button didn't just get silence. They fired off eleven, fifteen failed requests into the dark, and not one of them made a sound I could hear.",[29,312,314],{"id":313},"the-colour-that-never-came","The colour that never came",[15,316,317,318,321,322,325],{},"There was a quieter one still. On this card, the photo ",[45,319,320],{},"is"," the colour. Without it you get a flat gradient. iPhones shoot photos as HEIC, and an ",[79,323,324],{},"\u003Cimg>"," renders HEIC on Safari and almost nowhere else. So a card built and shared from a phone, opened by a friend on Chrome, lost its photo entirely. And the handler I'd written to be \"safe\" made sure no one ever knew:",[92,327,331],{"className":328,"code":329,"language":330,"meta":97,"style":97},"language-tsx shiki shiki-themes github-light github-dark","\u003Cimg src={imageUrl} onError={(e) => { e.currentTarget.style.display = \"none\" }} \u002F>\n","tsx",[79,332,333],{"__ignoreMap":97},[101,334,335,338,341,344,346,349,352,354,357,360,363,366,369,371,374],{"class":103,"line":104},[101,336,337],{"class":125},"\u003C",[101,339,18],{"class":340},"s9eBZ",[101,342,343],{"class":121}," src",[101,345,209],{"class":107},[101,347,348],{"class":125},"{imageUrl} ",[101,350,351],{"class":121},"onError",[101,353,209],{"class":107},[101,355,356],{"class":125},"{(",[101,358,359],{"class":257},"e",[101,361,362],{"class":125},") ",[101,364,365],{"class":107},"=>",[101,367,368],{"class":125}," { e.currentTarget.style.display ",[101,370,209],{"class":107},[101,372,373],{"class":221}," \"none\"",[101,375,376],{"class":125}," }} \u002F>\n",[15,378,379,380,384,385,388],{},"Hide the broken image. No error, no log, just a colourless card where a face should be. I've written before about ",[37,381,383],{"href":382},"\u002Fblog\u002Fquietest-bug","silence wearing the costume of safety",". Here it was again, in a different disguise. The fix was to stop trusting whatever the phone handed me and re-encode every upload to a plain JPEG the moment it's picked. And if it genuinely can't be decoded, to ",[45,386,387],{},"say so"," instead of swallowing it.",[29,390,392],{"id":391},"the-number-i-hadnt-looked-at","The number I hadn't looked at",[15,394,395,396],{},"Fixing the button felt like the work. It wasn't. The work was the number I'd never bothered to open: ",[58,397,398],{},"who was actually here.",[15,400,401],{},"On mobile, 148 people landed and 26 made a card. An 18% finish. On desktop, 42 landed and 17 made a card. Forty percent. Seventy-eight percent of everyone who came was on a phone, and on a phone they finished at less than half the rate they did on a desktop. I had poured the entire build into the 22% I could see. Not out of some decision, but because my machine happened to be one of them, and a laptop never once felt the dead button.",[29,403,405],{"id":404},"building-where-your-users-live","Building where your users live",[15,407,408],{},"So we rebuilt the create flow for a thumb, not a mouse. One screen instead of three. The card previewing live as you type, so the payoff arrives while you're still working instead of two taps later. \"Take a photo\" wired straight to the camera.",[15,410,411],{},[18,412],{"alt":413,"src":414},"The rebuilt mobile create flow: one screen, the card preview pinned to the top updating live as you fill the fields below.","\u002Fimages\u002Fblog\u002Fthe-button-that-did-nothing\u002Fcompose-mobile.png",[15,416,417,418,421],{},"And the part I'd change first if I could do it all again: we stopped ",[45,419,420],{},"pretending to be the platform",". Instead of hand-rolling a download the phone doesn't understand, we hand the finished card to the phone's own share sheet. The one button iOS actually knows how to press.",[92,423,425],{"className":94,"code":424,"language":96,"meta":97,"style":97},"if (navigator.canShare?.({ files: [file] })) {\n  await navigator.share({ files: [file], title, text })\n}\n",[79,426,427,441,455],{"__ignoreMap":97},[101,428,429,432,435,438],{"class":103,"line":104},[101,430,431],{"class":107},"if",[101,433,434],{"class":125}," (navigator.",[101,436,437],{"class":121},"canShare",[101,439,440],{"class":125},"?.({ files: [file] })) {\n",[101,442,443,446,449,452],{"class":103,"line":133},[101,444,445],{"class":107},"  await",[101,447,448],{"class":125}," navigator.",[101,450,451],{"class":121},"share",[101,453,454],{"class":125},"({ files: [file], title, text })\n",[101,456,457],{"class":103,"line":233},[101,458,459],{"class":125},"}\n",[15,461,462],{},"One line, and suddenly the button presses back. Save to Photos, WhatsApp, wherever. All the things the person was reaching for in the first place, through the door their phone already knew how to open.",[15,464,465],{},[18,466],{"alt":467,"src":468},"The mobile result screen: the finished card with a full-width \"Share your card\" button that hands off to the phone's native share sheet.","\u002Fimages\u002Fblog\u002Fthe-button-that-did-nothing\u002Fshare-mobile.png",[29,470,472],{"id":471},"make-the-silence-loud","Make the silence loud",[15,474,475],{},"Then we did the unglamorous thing: we made failure audible. Every failed share now carries its reason with it, so the next time something goes quiet I don't have to get lucky with a replay to find out.",[15,477,478],{},"Because the real bug was never the anchor tag or the header. Those were an afternoon. The real bug was that a person stood there tapping twenty times and the only reason I ever knew was that I happened to scrub through a recording weeks later. A dead button is bad. A dead button you can't see is how you quietly lose three-quarters of the people who came.",[29,480,482],{"id":481},"the-rule-i-keep","The rule I keep",[15,484,485],{},"Build on the device most of your users are actually holding, not the one you happen to own. And instrument every failure so it can't hide. The gap between a bug and a disaster is almost always just whether the button can tell you it's dead.",[15,487,488,489,493],{},"The card generator works on a phone now. If you've got a line your pastor never stopped saying, go make one at ",[37,490,47],{"href":491,"rel":492},"https:\u002F\u002Fwww.bethelflow.com\u002Fmy-pastor-once-said",[41],". Press the button. It should press back.",[495,496,497],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":97,"searchDepth":133,"depth":133,"links":499},[500,501,502,503,504,505,506,507],{"id":12,"depth":233,"text":13},{"id":31,"depth":133,"text":32},{"id":67,"depth":133,"text":68},{"id":313,"depth":133,"text":314},{"id":391,"depth":133,"text":392},{"id":404,"depth":133,"text":405},{"id":471,"depth":133,"text":472},{"id":481,"depth":133,"text":482},null,"2026-08-07","Most of my users were on a phone, tapping a download button that silently did nothing. My desktop never once felt it. What a session recording taught me about building where your users actually live.",false,"md",{},true,"\u002Fblog\u002Fthe-button-that-did-nothing","7min",{"title":5,"description":510},"blog\u002Fthe-button-that-did-nothing","AtXKCPpPRqpV1ql7LwpcZIZzfWzALKDClOozZLbIazk",1787080278178]