DEV Community

Vladimir Elchinov for Session Replay

Posted on

Your await Can Spend the User's Gesture Before You Use It

The copy button works. You have clicked it forty times. A user says nothing happens when they click it, and sends a screenshot of a button, which is not helpful because the button looks exactly the way it looks when it works.

There is a good chance the difference between you and them is not the browser, the extension or the operating system. It is how long their network took in the middle of your click handler.

The rule you are up against

A set of browser APIs will only run if the user has recently and deliberately interacted with the page. MDN calls that state transient activation, "a window state that indicates a user has recently directly and meaningfully interacted with the window".

Two properties of it are the whole story.

It expires. MDN says transient activation "expires after a timeout (if not renewed by further interaction)", and pointedly does not say how long that timeout is. It is not a number you can code against.

It can be consumed. The same page says it "may also be consumed/deactivated after using some gated features", with Window.open() as the example. Using one gated feature can leave you without activation for the next.

The shape that fails

button.addEventListener('click', async () => {
  const res  = await fetch('/api/share-link');   // the gesture is burning here
  const data = await res.json();
  await navigator.clipboard.writeText(data.url); // may now be refused
});
Enter fullscreen mode Exit fullscreen mode

That is ordinary, readable code, and it works perfectly on your machine, because fetch against a local API comes back in single-digit milliseconds and the activation is still alive when the clipboard call runs.

On a real connection that same round trip is a few hundred milliseconds. On hotel wifi or a train it is seconds. The code did not change. The gesture ran out.

So the failure is a function of latency, which is why the person who wrote it never sees it, and why "works for me" is true rather than careless.

It is a cluster, not one bug

Clipboard is the one people meet first, but MDN's list of features gated by transient activation is long and the same pattern breaks all of them. Element.requestFullScreen(). Window.open(). Navigator.share(). MediaDevices.getDisplayMedia(). HTMLInputElement.showPicker(). The file pickers, showOpenFilePicker() and friends.

If you have ever been told "the browser blocks popups", this is usually what was actually happening. The browser did not object to the popup. It objected to a popup opened with no live gesture behind it, which is a different rule with a different fix.

Why beforeunload is not like this

Worth knowing, because it is the thing that makes people think activation is simpler than it is.

MDN separates transient from sticky activation: "transient activation only lasts for a short while, and may in some cases be consumed (deactivated) when a protected feature is used, while sticky activation persists until the end of the session". The beforeunload event, autoplay and Navigator.vibrate() are on the sticky list.

So your unsaved-changes dialog fires reliably after any interaction at all, and your clipboard call does not, and the inconsistency is specified rather than accidental.

What to do about it

Call the gated API first, with whatever you already have, and do the slow part after.

button.addEventListener('click', async () => {
  await navigator.clipboard.writeText(buildUrlLocally());  // gesture still live
  track('copied');                                         // the slow part, after
});
Enter fullscreen mode Exit fullscreen mode

Where the value genuinely has to come from the server, the fix is to stop the gesture and the request from being in the same sequence: fetch it when the component mounts, or on hover, or on pointerdown rather than click, so that by the time the user commits, there is nothing left to wait for.

And do not let the call fail silently. All of these reject rather than returning a status, so a bare await with no catch produces exactly the symptom the ticket describes: nothing happened, no error on screen, nothing in your logs.

The part that makes it expensive

The fact that decides whether this works is how long one request took on somebody else's connection at the moment they clicked.

That number is not in the bug report. It is not in your server logs either, because the request you would want to time is the one that succeeded, and the thing that failed afterwards never reached you at all. The reporter cannot supply it, because nothing they can see distinguishes a slow success from a refusal.

Which is the ordinary shape of the reports that take longest: not difficult once you know, and invisible from where you are standing.

Top comments (0)