Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Should pageHideTimeout support additional events #13

Closed
mingyc opened this issue Jul 7, 2022 · 16 comments · Fixed by #16
Closed

Should pageHideTimeout support additional events #13

mingyc opened this issue Jul 7, 2022 · 16 comments · Fixed by #16
Labels
api Issue with API specs discussion

Comments

@mingyc
Copy link
Collaborator

mingyc commented Jul 7, 2022

pageHideTimeout: ... a timeout in milliseconds after the next pagehide event is sent, after which a beacon will be queued for sending, regardless of whether or not the page has been discarded yet.

The main intention for this property is to shorten the wait time after a page goes into bfcache and frozen. A page could send a beacon earlier before it's actually unloaded (or evicted from bfcache).

Some offline discussions suggest to not limit it to only pagehide, but also include visibilitychange or freeze, mainly for easier doing analytics. For now, users can approximate it by executing setTimeout(sendNow(), timeout) in the corresponding event listeners.

@mingyc mingyc added discussion api Issue with API specs labels Jul 7, 2022
@mingyc
Copy link
Collaborator Author

mingyc commented Jul 7, 2022

/cc @philipwalton

@philipwalton
Copy link

For now, users can approximate it by executing setTimeout(sendNow(), timeout) in the corresponding event listeners.

The problem with setTimeout() is it's not reliable in background tabs, especially on mobile, and especially in cases where the timeout is several minutes (e.g. 5 minutes).

I've just created a test case that uses navigator.sendBeacon() to deliver a payload to a backend server. I'd set it up to send data in the following scenarios:

  • visibilitychange event
  • visibilitychange event + setTimeout()
  • pagehide event

On desktop, this works reliably in all cases I tested, but on mobile I can get it to fail relatively easily. Here's how:

  1. Visit the test page on Android
  2. Use the app switcher to switch to a different app (visibilitychange event is logged)
  3. Go back to the app switcher and close the browser app without switching back to it
  4. Notice that no additional events are logged.

I can also get this to fail without closing the browser app if I choose a timeout like 5 minutes or more. If I choose a short timeout (e.g. 5 second) then I reliably get the visibilitychange:timeout log sent, but if I choose a long timeout, it's never sent.

The unreliability of DOM events after a tab (or the browser app itself) has been backgrounded on mobile is exactly why developers today have to rely on visibilitychange and cannot rely on pagehide alone. And for this reason, I think calling the option pageHideTimeout sends a confusing message to developers because we've long recommended against the use of pagehide as a reliable end-of-session signal.

See our Page Lifecycle guidance as an example of this:
https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden


Another problem with using pagehide only is that it's quite common for users to never close a tab (on mobile and desktop), and in those cases it doesn't make sense to delay sending any data until the user does close the tab (or the browser discards it, which may or may not happen).

For example, consider a web app like Gmail. When was the last time you closed that tab? You may keep it open for days or weeks. Do you really want to postpone sending any data until that tab is closed?

This is why I think a more useful feature would be backgroundTimeout, which would cover all hidden visibility states, including bfcache.

@fergald
Copy link
Collaborator

fergald commented Jul 19, 2022

Should we have a general timeout too? I mentioned this the last time I presented at WebPerfWG and it was pointed out that it was easy to do with setTimeout but as you point out setTimeout is not reliable and pages would have to write visibility change handlers to do something like

  • cancel the original setTimeout
  • set a new backgroundTimeout for the remaining time
    and then if the page becomes visible again, switch to a setTimeout again.

That all seems like a pain and just being able to set

  • timeout
  • backgroundTimeout
  • any other kind of timeout?

and have the beacon handle it seems nicer.

There's a clear use case for data to be delivered within N mins of some event that causes a beacon to be

  • created
  • updated
  • sent (i.e. you want the next beacon to be at most N mins since the last)

If someone was doing complex sending decisions and timeouts then they might want to set an N-mins from backgrounded timer since they know their complex logic is probably unreliable but do we actually have a concrete use case for N-mins from backgrounded if we could the ones above?

@philipwalton
Copy link

@fergald in additional to the use cases you described, I'm particularly concerned about cases where a user switches (on mobile) from the browser app to another app without closing the tab, and then hours or days later shuts down or restarts their phone before using the browser app again.

Do you think data could be sent in those cases?

We've heard from a number of RUM providers that it's unclear how they should report on performance data on a timeline for metrics like INP or CLS in cases where the user has the page open for multiple days. I see general timeouts being really useful for cases like this, e.g. send the this data at pagehide or at the next UTC date change (as one example), whichever comes first.

@fergald
Copy link
Collaborator

fergald commented Jul 23, 2022

@philipwalton my question is, what's a concrete use-case for pagehide-timeout that isn't met by just a general timeout? I.e. when is the preferred delivery time pagehide+N rather than (data_collected or some user/site-action)+N?

So I'm happy to add a general timeout, I just would like to be convinced that a pagehide timeout is really needed in addition.

If it is, then I would suggest we really want a "JS is frozen or timers become unreliable"+N timeout although that might be harder to spec since much of that is impl dependent.

@mingyc
Copy link
Collaborator Author

mingyc commented Jul 26, 2022

@philipwalton friendly ping

@fergald

That all seems like a pain and just being able to set

  • timeout
  • backgroundTimeout
  • any other kind of timeout?

by general timeout, do you mean to support separate timeouts for every visibility events like above, or just a single timeout and let browser decides when to use it?

@philipwalton
Copy link

philipwalton commented Jul 26, 2022

@philipwalton my question is, what's a concrete use-case for pagehide-timeout that isn't met by just a general timeout? I.e. when is the preferred delivery time pagehide+N rather than (data_collected or some user/site-action)+N?

I can't think of a good use case for pagehide specifically, as opposed to all background/frozen situations. However, I do think there's something nice about being able to configure everything in one place (e.g. when you create the PendingBeacon) rather than in a few places (e.g. at creation time and then adding your own event handlers). So if we believe that most developers want a background timeout rather than a general timeout, then I think a background timeout is still preferred.

I think my ordered preference would be as follows:

  1. backgroundTimeout: starts any time the page enters the "hidden" visibility state (and resets when visible again).
  2. timeout: starts/resets whenever new data is added.
  3. pagehideTimeout: starts only when the page is entering the bfcache (and resets if restored).

I believe (1) above is the best because if you want (2) you can easily implement it with setTimeout(), whereas you can't easily implement (1) with setTimeout() (as discussed in #13 (comment)). However, I definitely prefer (2) over (3). I've honestly never seen a strong use case for just (3).

@fergald
Copy link
Collaborator

fergald commented Jul 27, 2022

I propose we do 1 and 2.

I think 2 is important because, as you described above, because implementing 2 with just setTimeout is not trivial, given the unreliability of timers when backgrounded and many devs will not even know that the naive solution has problems.

I'm not sure about the resetting when new data is added though. I think there's a use-case of "some data within 5 min no matter what" and auto-reset makes that impossible. People who want to reset can always manually reset when adding data.

For backgroundTimeout timeout, how do we be precise? It is just visibilitystate change to hidden? If so, the name should involve those words. Or does it cover some other cases?

@philipwalton
Copy link

I'm not sure about the resetting when new data is added though. I think there's a use-case of "some data within 5 min no matter what" and auto-reset makes that impossible. People who want to reset can always manually reset when adding data.

When you say "manually reset" do you mean view some API method or just deactivating and creating a new beacon? (Either is fine with me.)

@fergald
Copy link
Collaborator

fergald commented Jul 27, 2022

I was thinking we could either have a method or a mutable property for the timeout(s) and setting it (even to the same value) resets the timer.

@mingyc
Copy link
Collaborator Author

mingyc commented Jul 27, 2022

I was thinking we could either have a method or a mutable property for the timeout(s) and setting it (even to the same value) resets the timer.

Wouldn't this intrdocue #8 again, i.e. things can happen between setData() & setTimeout()?
Also I feel that allow setting timeout upfront in constructor() which end up only working if setData() is called makes things confusing. Not to mention adding the support for setURL() (#6).

timeout: starts/resets whenever new data is added.

How about updating it to starts/resets whenever it is specified, and allowing to set it both within constructor() & setData(data, {timeout: x})&setUrl(data, {timeout: x})?

mingyc added a commit to mingyc/pending-beacon that referenced this issue Jul 27, 2022
- Fix WICG#13: Replace `pageHideTimeout` with `backgroundTimeout` and
  `timeout`. Allow to set them in constructor & data/url setters.
- Address WICG#14: Rename `isPending` to `pending` and specify its
  behaviors.
- Address WICG#14: Keep the low-level sync API, and mark it as chosen.
- Fix WICG#6: Add `PendingGETBeacon` and `PendingPOSTBeacon`.
@fergald
Copy link
Collaborator

fergald commented Jul 28, 2022

Wouldn't this introduce #8 again, i.e. things can happen between setData() & setTimeout()?

It's different because the timeout does not alter what gets sent, so there's no danger of a non-atomic update being interrupted by a crash.

So there's not need to combine the data and timeout in 1 method call.

@mingyc mingyc closed this as completed in #16 Aug 1, 2022
mingyc added a commit that referenced this issue Aug 1, 2022
* Update API spec in explainer to match latest discussion

- Fix #13: Replace `pageHideTimeout` with `backgroundTimeout` and
  `timeout`. Allow to set them in constructor & via property setters.
- Address #14: Rename `isPending` to `pending` and specify its
  behaviors.
- Address #14: Keep the low-level sync API, and mark it as chosen.
- Fix #6: Add `PendingGETBeacon` and `PendingPOSTBeacon`.
- Update some tables & formatting
- Addressed the comments in #16
@mingyc
Copy link
Collaborator Author

mingyc commented Sep 6, 2023

whatwg/fetch#1647 doesn't consider a general timeout. Re-opening this issue for further discussion.

@mingyc mingyc reopened this Sep 6, 2023
@fergald
Copy link
Collaborator

fergald commented Sep 8, 2023

I'm going to make 2 arguments for a general timeout rather than a timer that only starts when the page goes into BFCache (or maybe some other events):

JS Timers are unreliable

At least on Chrome and Firefox mobile

  1. go to this page
  2. putting the browser in the background
  3. wait 10 minutes
  4. go back to the browser

What you will find is that the interval callback stopped getting called after some time (Chrome=5min, Firefox=1min). No freeze (on Chrome) was delivered or any other event that I know of that would signal that timers had stopped working.

This means that it is impossible to reliably send a beacon after N minutes. A page has to choose between sending on visibilitychange (as the last time they have guaranteed control) or some unspecified time in the future.

Using timers from JS and backgroundTimeout is unergonomic

Assuming reliable JS timers, if a page wants to send after N minutes, this is the code they must write.

function fetchLater(url, limitMs) {
  const finalTime = Date.now() + limitMs;
  const timeLeft = () => {
    return finalTime - Date.now();
  }
  let abortController = new AbortController();
  let fetchResult = fetchLater(url, { signal: abortController.signal });

  const pagehideHandler = e => {
    if (!e.persisted) {
      // Not going into BFCache.
      return;
    }
    if (fetchResult.activated) {
      // It was already sent.
      removeEventListener("pagehide", pagehideHandler);
      return;
    }
    abortController.abort();
    abortController = new AbortController();
    fetchResult = fetchLater(url, {
      signal: abortController,
      backgroundTimeout: timeLeft()
    });
  }
  addEventListener("pagehide", pagehideHandler);

  // Send it on time.
  setTimeout(() => {
    removeEventListener("pagehide", pagehideHandler);
    if (fetchResult.activated) {
      // It was already sent.
      return;
    }
    abortController.abort();
    fetch(url);
  }, timeLeft());
}

This code does not handle freeze. That would make it even more complex.

Maybe it can be made smaller while still being correct but not much. That's a lot of code and a lot of understanding required by devs vs

fetchLater(url, {timeout: limitMs});

@yoavweiss
Copy link
Collaborator

Also, freeze is not currently interoperably supported AFAIK, and it may be unfortunate to tie the reliability of fetchLater to its eventual implementation.

@mingyc
Copy link
Collaborator Author

mingyc commented Nov 13, 2023

The timeout field is now called activateAfter in `fetchLater(url, {activateAfter: X}) which is event-independent. See whatwg/fetch#1647 (comment)

@mingyc mingyc closed this as completed Nov 13, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api Issue with API specs discussion
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants