A copy button's click handler does `const text = await fetch('/doc').then(r => r.text())` and then calls `navigator.clipboard.writeText(text)`, and the write rejects with a NotAllowedError even though the user definitely clicked. What browser rule causes this, and how do you restructure the handler?
answer
- the click grants a token
- sticky versus transient
- it expires and is consumed
- the await moves the call later
- prefetch, or pass a promise
basics
~20 sClipboard writes require transient user activation — a short-lived, consumable token created by the click. Awaiting a network round trip lets it expire, so the later call is treated as unprompted. Fetch the text before the click, or hand ClipboardItem a promise.
solid answer
~50 sThe clipboard write is gated on **transient user activation**: the click grants the document a flag that is short-lived and consumable, not a permanent "the user has interacted" mark. Awaiting a fetch moves the write into a later task, by which time the activation may have expired — Chromium and Firefox use a window of a few seconds — so the browser treats the call as script-initiated and rejects with `NotAllowedError`. The distinction the specification draws is *sticky* activation, meaning the user has ever interacted with the document, versus *transient* activation, which is recent, expires, and is spent by APIs that consume it. The fix is to remove the await from the critical path: have the text in memory before the click, prefetch on hover or focus, or construct a `ClipboardItem` whose data is a promise so the browser keeps the activation while the data loads. Reordering also helps — do the gated call first, then the slow work.
code
javascript · 17 linesconst button = document.querySelector('#copy');
let cached = null;
// Warm the data before the gesture that needs it.
button.addEventListener('pointerenter', async () => {
cached ??= await fetch('/doc').then((r) => r.text());
});
// The gated call runs synchronously inside the click.
button.addEventListener('click', async () => {
if (!cached) return;
try {
await navigator.clipboard.writeText(cached);
} catch (err) {
console.warn('clipboard write refused:', err.name);
}
});go deeper
Know that some browser features only work when called directly from a real user gesture such as a click, and that doing slow asynchronous work first can lose that permission. Have the data ready before the click.
Explain sticky versus transient activation, that transient activation both expires and is consumed, and why an await is the usual culprit because it defers the call into a later task.
Show how you would diagnose a latency-dependent report: reproduce with throttled network, distinguish NotAllowedError from a permission denial, and restructure with prefetching or a promise-backed ClipboardItem rather than papering over it.
Own the pattern across the product: gated calls belong in a thin synchronous layer directly under user input, with data preloading as a convention. Decide what the fallback is when a gated API is unavailable, since these rules tighten over time.
## The rule Several browser capabilities are abusable if a script can invoke them whenever it likes: writing the clipboard, opening a popup, going fullscreen, and in some browsers raising a notification permission prompt. Rather than a stored permission, these are gated on **user activation** — evidence that the current invocation traces back to a real user gesture. HTML defines two flavours, and confusing them is the root of this bug. - **Sticky activation** — the user has interacted with the document at least once since it loaded. It never expires. It gates things that only need "this is not a drive-by page", such as autoplaying audio. - **Transient activation** — the user interacted *recently*. It has a lifetime measured in seconds (Chromium and Firefox use roughly five), and certain APIs **consume** it, clearing it even before the timer runs out. The gated clipboard write needs transient activation. A click sets the flag; the handler then awaits a network round trip; the promise resolution runs in a later task, potentially seconds afterwards, and by then the flag may be gone. The call looks exactly like one made from a timer, which is the case the rule exists to block, so it rejects with `NotAllowedError`. ## Why an await is the trigger Nothing about `await` is special to the browser — it is that the code after it runs in a *later* task, after the promise settles. The same failure appears with `setTimeout`, with a promise chained onto an unrelated pending operation, or with a callback fired by a `MutationObserver`. Any hop that puts real time between the gesture and the gated call risks it. Conversely, work that completes synchronously, or in a microtask within the same turn, is normally still inside the window. The consequence is that the failure is *timing-dependent*, which is why it so often ships. On a fast local network the fetch settles in 20 ms and the copy works; on a slow connection, or on a cold cache behind a slow origin, it exceeds the window and users report "copy does nothing". Engines and versions differ slightly in strictness — WebKit is notably tighter about clipboard writes than Chromium — so "works in my browser" proves little. ## Restructuring the handler **Have the data ready.** The most reliable shape is to make the gated call synchronously with data already in memory: ```js let doc = null; button.addEventListener('pointerenter', async () => { doc ??= await fetch('/doc').then((r) => r.text()); }); button.addEventListener('click', () => { if (doc) navigator.clipboard.writeText(doc); }); ``` **Hand the API a promise.** The Clipboard API's `ClipboardItem` constructor accepts a promise for each format's data, which exists precisely so the write can be initiated inside the gesture while the bytes are still loading. WebKit has supported this pattern longest; support elsewhere has improved, so feature-detect `window.ClipboardItem` before relying on it: ```js button.addEventListener('click', () => { const blob = fetch('/doc') .then((r) => r.text()) .then((t) => new Blob([t], { type: 'text/plain' })); navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]); }); ``` **Reorder.** If the handler must both call a gated API and do slow work, do the gated call first and the slow work after. **Do not** try to defeat the rule by re-dispatching a synthetic click. Events created by script have `isTrusted === false` and grant no activation; that is the whole design. ## Checking the flag Chromium-based browsers expose `navigator.userActivation` with two booleans — `isActive` for transient and `hasBeenActive` for sticky — which is useful for logging why a gated call failed. Support is not universal, so feature-detect and never make it load-bearing: ```js if (navigator.userActivation && !navigator.userActivation.isActive) { console.warn('activation expired before the gated call'); } ``` ## The consumption half Expiry is only one way to lose activation; some APIs spend it. `window.open()` consumes transient activation, so two calls in one click handler yield one popup and one block — the second call has nothing left to spend. Understanding consumption explains a family of "only the first one works" bugs that no amount of timing analysis would reveal. ## Which APIs are gated The set is not fixed and grows over time, but the recognisable members include clipboard reads and writes, `Element.requestFullscreen()`, `window.open()` when the popup blocker is active, and — in Firefox and Safari — `Notification.requestPermission()`. When you meet a new API that fails only from timers, transient activation is the first hypothesis to test.
- A click handler calls window.open() twice and only one popup appears. Is that the same rule?Yes, but the consumption half rather than the expiry half. `window.open()` spends the document's transient activation, so the second call in the same handler has none left and the popup blocker stops it. One gesture buys one consuming call; if you genuinely need two windows, you need two gestures.
- Why can't you fix this by dispatching a synthetic click event before the gated call?Events constructed and dispatched by script carry `isTrusted === false` and grant no user activation — that is exactly the bypass the design forbids. The activation comes from the browser's own input handling, so nothing script can synthesise will restore an expired flag.
- How would you make this failure visible in testing rather than in production?The bug is latency-dependent, so exercise the handler with the network throttled and with a cold cache, and assert on the rejection rather than swallowing it. Log `navigator.userActivation.isActive` where available at the moment of the gated call, so a failure report distinguishes an expired activation from a denied permission.
User activation is a ticket the click hands you, not a season pass: it is only valid for a few seconds and some turnstiles take it from you as you go through.
saying these in an interview costs you the question
- Says any click permanently authorises the page
- Blames a clipboard permission the user must grant
- Proposes dispatching a synthetic click to regain activation
- Assumes it works everywhere because it works locally
- Wraps the call in setTimeout to 'let things settle'