skip to Main Content

This example provided to explain tasks vs microtasks doesn’t make sense to me. Shouldn’t the event listener be executed after the current task has finished, so “Data fetched” should get logged before “Loaded data” in the case of it being cached? And in the case of not being cached it’ll be the same because the microtask gets run after the initial task. What am I missing?

https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide#

‘One situation in which microtasks can be used to ensure that the ordering of execution is always consistent is when promises are used in one clause of an if…else statement (or other conditional statement), but not in the other clause. Consider code such as this:

customElement.prototype.getData = (url) => {
  if (this.cache[url]) {
    this.data = this.cache[url];
    this.dispatchEvent(new Event("load"));
  } else {
    fetch(url)
      .then((result) => result.arrayBuffer())
      .then((data) => {
        this.cache[url] = data;
        this.data = data;
        this.dispatchEvent(new Event("load"));
      });
  }
};

The problem introduced here is that by using a task in one branch of the if…else statement (in the case in which the image is available in the cache) but having promises involved in the else clause, we have a situation in which the order of operations can vary; for example, as seen below.

element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data…");
element.getData();
console.log("Data fetched");
Executing this code twice in a row gives the following results.

When the data is not cached:

Fetching data
Data fetched
Loaded data

When the data is cached:

Fetching data
Loaded data
Data fetched

…‘

2

Answers


  1. The example from MDN… maybe assumes the reader knows too much?

    The problem that the example is trying to highlight is that the getData function is synchronous in one branch and asynchronous in the other, and this leads to bugs. Consider that the caller of getData doesn’t understand they need an event listener and makes some assumptions about whether the .data property is populated:

    element.getData();
    element.data.forEach(console.log);
    

    in the cached case, this code works fine. In the fetch case, it blows up with a runtime error. But there are even more pernicious problems:

    element.getData();
    await someAsyncFunctionThatReliesOnElementData();
    

    Here we have potential for even more subtle bugs, because now in the fetch case the call to someAsyncFunctionThatReliesOnElementData might still succeed if it takes long enough that the data has come back (since it’s async). In other words, we’ve now introduced a potential race condition on top of the other problems.

    Now, all of this goes away if the caller just listens for the element’s load event like they’re supposed to, but the point is that by queueing the microtask in the cache case that you’ve eliminated an entire class of misuse of the API: anyone who assumes that it synchronously sets the .data property will get a fast failure instead of an intermittent one.

    I think part of the problem is that this whole thing is horribly contrived. There are better ways to write something like this, included but probably not limited to removing the data property entirely and:

    • Having an async method for getting values (see Pointy’s community wiki answer). No misuse possible.
    • Attaching the data to a CustomEvent and fire that. It’s not as good as the async method because if listeners aren’t registered and miss the event they don’t get the data, but they can’t misuse the element object.
    • Both of the above together.

    In other words, the fix here is to refactor the element’s API rather than muck around with queueMicrotask.

    Login or Signup to reply.
  2. Posting this because it’s easier than commenting: now that we have async functions, I think that first example function would me made much clearer by being written that way:

    customElement.prototype.getData = async function(url) {
      if (this.cache[url]) {
        this.data = this.cache[url];
        this.dispatchEvent(new Event("load"));
      }
      else {
        const result = await fetch(url);
        const data = await result.arrayBuffer();
        this.cache[url] = data;
        this.data = data;
        this.dispatchEvent(new Event("load"));
      }
      return "getData finished";
    };
    

    (Note that another issue with that example was that the function should not be an => function, because this won’t work.)

    Now in the if there’s (effectively) one Promise resolution and in the else there are two, but the problem mentioned in that article is basically "solved". Calling .getData() now (with await):

    await element.getData();
    // .data will always have been updated here
    

    Kind of skirts the whole issue, because that’s what await does. In the synchronous if branch, the update and event firing are immediate, but the calling environment will still wait for the implicit Promise to resolve.

    I think a bug should be logged on the MDN page because it certainly would be possible to come up with a less contorted example.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search