skip to content

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?

level: middleimportance: should knowfreq 42%

answer

  1. the click grants a token
  2. sticky versus transient
  3. it expires and is consumed
  4. the await moves the call later
  5. prefetch, or pass a promise

basics

~20 s

Clipboard 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 s

The 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 lines
javascript
const 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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'

context