Yoav's blog thing

Mastodon   RSS   Twitter   GitHub  

Task Attribution Semantics

I’ve been thinking a lot lately about task attribution semantics and learning a ton about that subject, so I thought I’d document the ways in which I’ve come to think about it.

This may not be a subject everyone would find fascinating, so it’s mostly aimed at other like-minded folks who’ve been thinking about this space a lot, or that one person that thought about this a lot and then got distracted and forgot everything. (AKA - future me)

# What’s Task attribution again?

When I’m talking about task attribution, I’m talking about the browser’s ability to track why it’s running the current task, and its ability to attribute that task to some party on the document, or to past tasks.

This is an important capability because it enables us to create causality related heuristics and algorithms. If action X triggered action Y, then do Z. That’s an extremely powerful primitive that the web has been missing for a long while.

We now have that in Chromium (and use it for Soft Navigation heuristics and ResourceTiming initiators, both still experimental). I’ve put together a spec for it, and am hoping that other engines follow.

I wrote more about it a while ago, but I should really update that with a proper post. Maybe soon!

# Different semantics of task attribution

What am I talking about when I’m saying “semantics”? Essentially, there are multiple different ways in which we can attribute a certain task to other tasks or to the party that initiated it.

Those different ways are not always obvious, because for the straightforward case, they all kinda give you the same answer. As a result, it took me a while to wrap my head around their differences, and I’m hoping to save someone some time by outlining them.

So, what different types of task attribution we may have?

# Provenance

That's a fancy word for saying "where is this thing coming from?". That can be done by inspecting the JS stack and picking the top frame. It can tell us which JS file loaded the current function, and in many cases, that can be enough.

# Registration

A slightly more complex example is one where we attribute a task to the party or task that registered the code. (See below for a caveat on that)

Basically:

// This is Task A
document.addEventListener(“load”, () => {
// When task B runs, it knows it was registered by Task A
});

You can already sorta kinda achieve that today by wrapping addEventListener and other functions that queue callbacks, and e.g. annotate the callbacks that are passed to it with information about the task that registered them. Angular’s zone.js does something very similar.

# Caller

The slightly more complex semantic you may want is caller attribution - you want to attribute the task to the party or task that called it.

How is that different?

Let’s say you have a custom element called <my-element>.

Your main script defines it, and registers its connectedCallback().

Then some other script, running as a result of an unrelated task, creates a <my-element> and connects it.

Which task would you say caused the connectedCallback() to run? The main script that registered it, or the other script task that caused it to be called?

While registration semantics would say the former, caller semantics require the latter.

So, with this semantic, in those scenarios, you want to be able to know which task initiated the call, not which task registered it.

The tricky part about caller semantics is that they need to be applied on every callback type separately. Different callbacks get called when different things happen in the browser, and in order to maintain caller semantics we need to know what those things are for each API.

Caller semantics get even more complicated when we consider promises, and how we want to attribute related continuations.

# Wait, what’s a continuation?

Let’s say we have the following code:

(async () => {
DoSomeThings();
await new Promise(r => AsyncWork(r));
DoSomeMoreThings();
})();

DoSomeMoreThings() is a continuation of the async function that awaited on the AsyncWork() Promise. The same applies if we were to write the code without any await syntax:

(() => { 
DoSomeThings();
(new Promise(r =>AsyncWork(r))).then(DoSomeMoreThings());
})();

So, what task do we want to attribute DoSomeMoreThings() to? The one that ran this code initially (and then it’d be in the same conceptual task as DoSomeThings())? Or the one that resolved AsyncWork()?

We have two options here:

# Continuation resolver

With this option, we’re going to attribute that task to whatever task resolved AsyncWork(). That’s the approach that e.g. LongAnimationFrames is taking.

That’s a legitimate approach, that enables you to know why a certain task is running now, rather than earlier or later.

# Continuation registration

A different approach would be to treat the awaited Promise as a distraction, and attribute the task to the original task that awaited on that Promise. In that case you would consider DoSomeMoreThings() the same task as the one that called DoSomeThings(), with the task that resolved AsyncWork() being a completely different one.

# But.. but why?

I know the above can be a bit confusing and a lot to take in in one sitting. It took me many months of working on this to fully realize all the complexity and the different cases that apply to each kind of semantic.

But we need all these different kinds of semantics when we want task attribution to answer different questions for us. Each of them answers a subtly different question:

# Who's code is it?

That's a straight-forward question with a straight-forward answer. Provenance semantics (AKA - looking at the top of the stack) can help us answer that relatively easily, by pointing at the JS file with which the current function was downloaded. While this can be trickier at times (e.g. one script evaling a string that arrived from somewhere else), the common case is relatively simple.

# Who wanted this code to run?

That’s a question that’s answered by registration semantics. It’s very similar to observing the JS stack in devtools or in errors, and trying to find the “guilty” party up the stack, only that the “stack” now expands to the task that registered that callback, and the tasks that initiated it.

As a prime example of that, consider a library that runs a DoLotsOfWork() function whenever the page calls its async scheduleWork() function. When we’re trying to attribute the work that DoLotsOfWork() is doing, we need to go beyond the superficial, and understand who is calling the code that schedules it? That can be helpful in order to tell them to quit it.

In many cases, that kind of task attribution is enough. And it is the simplest.

# Who is running this code?

That’s a question that is subtly different from the above one.

A few examples of that difference:

With web components lifecycle callbacks, you can have the page register these callbacks at load time, but have them be triggered by different code (e.g. click handlers) that create and attach these custom elements to the DOM.

Similarly, in coding patterns such as React’s useEffect, the setup callback would run whenever a component is added to the page or gets re-rendered, which can again happen by asynchronous code.

Another example could be e.g. User Timing observers that are registered by one party (e.g. your RUM provider), but triggered by another (e.g. your code that wants to time certain milestones).

In these examples, we want to be able to say who is running that code. Who attached a new component to the DOM? Who created a user timing measurement?

This question is answered by caller semantics, and specifically their continuation registration variant. That enables us to know which task is running the code, even if along the way we awaited data or something else to happen.

This is the semantics that Chromium’s TaskAttribution is implementing and that enables us to e.g. attribute DOM changes to a click event handler in code that looks something like:

let prefetch_promise;
link.addEventListener(“hover”, () => {
prefetch_promise = new Promise(r => {
fetch(next_route).then(r);
});
};

link.addEventListener(“click”, async () => {
TurnOnSpinner();
await prefetch_promise;
AddElementsToDOM(response);
});

Due to continuation registration caller semantics, we can attribute the DOM changes in AddElementsToDOM to the click event handler task, and not to the prefetch one.

# Why is it running now?

But, we could be interested in yet another question, to which the answer is continuation resolver semantics. That answer is slightly simpler than the continuation registration one, as it doesn’t require us to maintain state on continuations created by the JS engine, and we can keep all the state on the web platform side of things.

This could’ve been the right answer to e.g. a use case like Soft Navigation Heuristics if a pattern like the following would’ve been common:

let click_promise = new Promise (r => {
link.addEventListener(“click”, r);
});
link.addEventListener(“hover”, () => {
prefetch_promise = new Promise(async r => {
const response = await fetch(next_route);
await click_promise;
AddElementsToDOM(response);
});
};

Here, if we wanted to attribute the DOM element addition to the click event, we’d need the “why is it running now?” answer, so that would require continuation resolver semantics.

Because this is not a common pattern we’ve seen in the wild, that’s not what we ended up doing with Chromium’s TaskAttribution, at least for now.

Continuation resolver semantics can get slightly more complex in cases where we’re waiting on multiple promises.

Let’s say you wanted to do the following (which no one should never ever do, for multiple reasons):

const carousel = new Promise(r => loadCarousel(r));
const menu = new Promise(r => renderMenu(r));
const content = new Promise(r => renderContent(r));

Promise.all([carousel, menu, content]).then(() => {
loadSubscribeToNewsletterPopup();
};

In that case, continuation resolver caller semantics enable us to assign the responsibility for the timing in which the newsletter popup appeared to the last of the promises that were resolved.

# Is "task" the right abstraction?

In registration semantics we talked about callbacks having their registration task as their parent task. Reality is actually slightly more complicated than that, as we can have multiple tasks register callbacks that all run as part of the same event loop task.

In Chromium’s Task Attribution implementation, each one of these callbacks would have its own task ID.

In that sense, one can say that tasks in the context of attribution are somewhat decoupled from HTML’s tasks, and a rename may be in order. (E.g. to Context)

Other implementation-motivated changes like trimming of long task-chains also indicate that it might be worthwhile to compress Tasks into Contexts.

# In summary

Tasks on the web are complex, and attributing those tasks is no different.

There are many different ways to think about tasks and their attribution: who defined them? Who scheduled them to run? Who runs them? And why are they running now?

The concepts I outline above are aimed to be web specification concepts. I hope they can be used by different high level features (e.g. Soft Navigation Heuristics) to Do The Right Thing™.

I similarly hope that these are not concepts web developers would ever have to think about. Browsers should make sure that high-level APIs use the right semantics for them, so that developers don’t have to care.

Thanks to Michal Mocny, Annie Sullivan and Scott Haseley for their reviews and insights on an early draft of this post!

← Home