The await That Silently Breaks navigator.clipboard.writeText()
Tech

The await That Silently Breaks navigator.clipboard.writeText()

Someone on your team ships a "Copy invite link" button. It fetches a fresh, single-use link from the API, then copies it to the clipboard so the user can paste it into Slack. Code review is clean. QA clicks it a dozen times. Works every time. Two weeks later, a support ticket: "I click Copy, I paste into Slack, and I get yesterday's clipboard contents. Not the link." Another ticket, same shape, different user. You can't reproduce it. You click the button forty times in a row and it copies the link forty times. Here's the detail that breaks the case open, if you know to look for it: every user who hit this had switched to another tab or clicked into another window in the second or two between clicking Copy and the link actually landing on their clipboard. Nothing crashed. No error reached the UI. navigator.clipboard.writeText() just quietly declined to run, and the code never checked. The fixes that don't touch it The instinct is to treat it as a normal race condition or a network hiccup: Add a loading spinner so the user waits for the fetch to finish before clicking. Doesn't help — the bug isn't about clicking too early, it's about what happens after the click, while your code is still awaiting something. Wrap the write in try/catch and just eat the error. Now it fails the same amount, but silently on purpose instead of silently by accident. Worse, arguably — you've deleted your own evidence. Retry the write a moment later. If the reason it failed is still true (the document still isn't focused), the retry fails too. If you retry indefinitely, you've built a poller for a permission that has nothing to do with time. None of these ask the one useful question: why does a browser API that "just copies a string" refuse to run at all? The real rule: the API only trusts the instant of the click navigator.clipboard.writeText() is part of the Async Clipboard API, and it enforces something stricter than "the user clicked a button once, somewhere." At the exact moment you call it, the browser wants two things to still be true: The document has focus. Not "had focus when the click happened" — has it right now, this call, this tick. The user gesture is still active. Clicks grant a short-lived window of "the user just did something," and that window doesn't wait around forever. Await literally anything — a fetch() for the link, a promise chain, even a couple of re-renders — and you've inserted a gap between the click and the actual writeText() call. If the user alt-tabs, clicks a browser chrome element, or a dev-tools panel steals focus during that gap, the call arrives with the document unfocused. The browser doesn't queue it, doesn't warn the user, doesn't retry. It rejects the promise with a NotAllowedError — in Chrome, literally "Failed to execute 'writeText' on 'Clipboard': Document is not focused." — and if nothing in your code reads that rejection, it vanishes into an unhandled-promise-rejection log line nobody watches production for. // looks completely reasonable, fails silently under real-world timing async function copyInviteLink() { const res = await fetch("/api/invite-link"); // <- the gap opens here const { url } = await res.json(); await navigator.clipboard.writeText(url); // <- and this is what falls in it } Nothing here is wrong syntax. It's wrong sequencing — the write happens whenever the network happens to resolve, not while the click is still fresh. 🎮 Try it yourself ▶️ Open the interactive playground → Runs right in your browser — poke at it and watch the concept react live. The fix: give the API the gesture immediately, the data later writeText() is a convenience method — it only accepts a string you already have in hand, right now. But its sibling, navigator.clipboard.write(), takes a ClipboardItem, and a ClipboardItem's data doesn't have to be a plain string or Blob. It can be a Promise that resolves to one later. That's the actual tool for "I have the user's permission right now, but not the text yet": // call write() synchronously, in the same tick as the click — // the *data* is allowed to arrive whenever it's ready function copyInviteLink() { const linkPromise = fetch("/api/invite-link") .then((res) => res.json()) .then(({ url }) => new Blob([url], { type: "text/plain" })); return navigator.clipboard.write([ new ClipboardItem({ "text/plain": linkPromise }), ]); } The write() call itself still happens inside the click handler, before any await has had a chance to let focus slip — so the permission check passes at the one moment it's guaranteed to be true. The browser holds the clipboard slot open and fills it in once your promise settles. Nothing about your fetch logic has to change; only which method you hand the eventual string to. Two details worth knowing before you ship this The whole API requires a secure context. navigator.clipboard simply isn't there on plain http:// origins (outside localhost) — if it's undefined in production but not on your machine, that's almost always why. document.execCommand("copy") is deprecated, per MDN, and not guaranteed to work or even exist in every browser going forward. It's still floating around in older code because it predates the Async Clipboard API and doesn't have this exact focus problem — it copies synchronously from a selection, no promise involved — but it's not the thing to reach for in new code, deprecated fallback or not. The one thing worth remembering A copy button that works in every manual test and fails for real users isn't flaky — it's timing-dependent in a way your test never reproduces, because you never alt-tab mid-click when you're the one testing it. The rule is simple once you name it: keep the call to the Clipboard API synchronous with the gesture that authorizes it, and if the data isn't ready yet, hand the API a promise instead of making it wait for one. Have you shipped a copy-to-clipboard button that "just doesn't work sometimes" and quietly caught the error instead of asking why? What did the try/catch look like? 🧠 Test yourself Think it clicked? Take the 8-question quiz → Instant feedback, a hint on every question, and an explanation for each answer — right or wrong. 🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you. Thanks for reading! Let's stay connected: ⭐ GitHub — follow me and star the projects: github.com/parsajiravand 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ 📸 Instagram — frontend best practices, daily: @bestpractice___

Read full story →

Comments

Loading comments…

Related