- Preface
- Definitions and Concepts
- Introduction
- Goals
- Non-goals
- User research
- Proposal
- Considered alternatives
- Stakeholder Feedback / Opposition
- References & Acknowledgments
This explainer brings together various past, present, and planned scheduling APIs proposals into a single explainer. Some of the APIs presented have separate longer explainers, which are linked in the relevant sections, and some ideas have not been fully designed.
Tasks and continuations are fundamental concepts in scheduling, and at least the former is a very overloaded term. Brief descriptions of these concepts follow, but they are also discussed in depth in Task Models.
In this document a task typically means a synchronous chunk of JavaScript executed
asynchronously, i.e. scheduled or executed in response to some async event. Scheduled tasks include
things like setTimeout()
and requestIdleCallback()
callbacks, and async events include things
like click event handlers1.
Yielding within a task is to pause and resume execution in another task. We call the resuming
task a continuation. A yieldy task is a task that is broken up into an initial task and one
or more continuations. Continuations can be scheduled using the same APIs as tasks (e.g.
setTimeout()
), and from the browser's point of view both the task and continuations are HTML
tasks; but from the
developer's point of view, they are related and part of the same logical task.
Scheduling can be used to improve site performance, specifically responsiveness and user-perceived latency (or end-to-end latency). These measure latency on different timescales. Responsiveness refers to how fast a page is able to respond to user input, which Interaction to Next Paint (INP) attempts to measure using the primitives in Event Timing. User-perceived latency is an application- and event-specific measure of latency, e.g. how long an SPA navigation takes or how long it takes to fetch and display results when clicking a "search" button.
1In this context we typically only think of top-level JS execution as a task, as opposed to events dispatched during a task.
Scheduling is an important tool for improving website performance and user experience, particularly on interactive pages with a lot of JavaScript. Two important aspects of this are:
-
Yielding, or breaking up long tasks. Long tasks limit scheduling opportunities because JS tasks are not preemptable. Long tasks can block handling input or updating the UI in response to input, which is often a cause of poor responsiveness.
-
Prioritization, or running the most important work first. The (task/event loop) scheduler determines which task runs next on the event loop. Running higher priority work sooner can improve user experience by minimizing user-perceived latency of the associated user interaction.
Modern pages and frameworks often do some form of this, ranging from breaking up long tasks with
setTimeout()
to building complex userland schedulers that
manage prioritization and execution of all internal tasks. While these approaches can be effective,
there are gaps and rough edges with existing scheduling APIs that make this difficult. For example:
-
requestIdleCallback()
is the only way to schedule prioritized tasks. It's helpful for deprioritizing certain types of work, but can't be used to increase priority of important tasks or be used to prioritize I/O. To gain higher priority, developers might userequestAnimationFrame()
as a hack to get higher priority (with UA-specific results), but that can negatively affect rendering performance by delaying visual updates since rAF callbacks run before a new frame is produced. In general, UA schedulers are unaware of high priority userland tasks, which limits their ability to effectively prioritize work. -
Continuations scheduled with existing APIs are indistinguishable from other tasks, and the pending continuation is appended to the end of the relevant task queue. Task continuations aren't prioritized, which can lead to a performance penalty (latency) for yieldy tasks to regain the thread after yielding.
-
setTimeout()
clamps at 4ms if sufficiently nested, which impacts performance when using it to frequently yield. Developers often need to hack around this, e.g. by usingpostMessage
instead; butpostMessage
was designed for cross-window communication, not scheduling. -
There's no way to specify a priority on I/O-related APIs like
fetch()
and IndexedDB, which limits the effectiveness of userland scheduling.
The main goal of this work is to facilitate improving site performance (responsiveness and latency) through better scheduling primitives, and specifically:
-
provide an ergonomic and performant way to break up long tasks, reducing the end-to-end task latency of yieldy tasks compared to current methods;
-
provide a way to schedule high priority tasks and continuations;
-
enable prioritizing select async I/O main thread tasks, e.g.
fetch()
and async<script>
; -
enable the browser to make better internal scheduling decisions by being aware of userland task priorities;
-
provide a cohesive set of scheduling APIs using modern web primitives, e.g.
AbortController
, promises, etc.
-
It's a non-goal to to replace every userland scheduler. Rather, our goal is to provide primitives that these schedulers, and pages in general, can use to improve scheduling/performance.
-
It's a non-goal to change JavaScript's run-to-completion semantics. Making JavaScript tasks preemptable would significantly improve responsiveness, but such a paradigm shift is outside the scope of this proposal.
No user research was performed specifically for this proposal, but there have been studies on input latency in computing, some of which is discussed in this event-timing issue.
(See also the original scheduler
explainer and
specification.)
The proposal centers around a small set of semantic task priorities, which are used to schedule tasks and continuations, and which are integrated into new and existing APIs. Semantically meaningful naming helps developers understand when it is appropriate to use a given priority and enable easier coordination, and there is precedent in other systems. Similar priorities (and a similarly small set) can be found in other platforms like Apple's QoSClass and Chromium's internal browser task queues.
-
user-blocking: tasks that are essential for user experience. This is the highest priority and should be reserved for tasks that should run as soon as possible, such that running them at a lower priority would degrade user experience. This could be (chunked) work that is directly in response to user input, or updating the in-viewport UI state, for example.
User-blocking tasks are meant to have a higher priority in the event loop compared to other JS tasks, i.e. they are prioritized by UA schedulers. But while they run at a high priority, they are not guaranteed to block critical tasks like rendering and input. So unlike synchronous code, user-blocking tasks provide a way to break up critical work while still remaining responsive to input.
-
user-visible: tasks that will be visible to the user, but either not immediately or are not essential to user experience. These tasks are either less important or less urgent than user-blocking tasks.
This is the default priority used for
postTask()
and theTaskController
constructor, and it is meant to be scheduled by UAs similarly to other scheduling methods, e.g. same-windowpostMessage
andsetTimeout(,0)
. -
background: Background tasks are low priority tasks that are not time-sensitive and not visible to the user.
Background tasks are meant to have a lower priority in the event loop compared to other JS tasks. These tasks are comparable to idle tasks scheduled by
requestIdleCallback()
, but without the requirements that come with an idle period (deadlines, idle period length, etc.).
(See also the original scheduler
explainer and
specification).
scheduler.postTask(task, options)
enables scheduling prioritized tasks such that the priority
influences event loop scheduling (see Task Priorities). This is primarily used
to break up long tasks on function boundaries.
Example: Breaking up a long task with scheduler.postTask()
.
function longTask() {
const task1 = scheduler.postTask(doWork);
const task2 = scheduler.postTask(doMoreWork);
return Promise.all([task1, task2]);
}
The API returns a promise which is resolved with the return value of the callback.
Example: scheduler.postTask()
return value.
function task() { return 'example'; }
const result = await scheduler.postTask(task);
console.log(result); // 'example'
Breaking up long tasks helps improve responsiveness since long tasks can block input handling.
Prioritization can help reduce latency of important work by either deprioritizing background work
or prioritizing the important work. The default option ('user-visible'
) has similar scheduling
characteristics as existing APIs, e.g. setTimeout()
.
For simplicity, the API takes a priority
option which can be used when no control (TaskSignal
)
is necessary.
Example: Scheduling prioritized tasks with the priority option.
function longTask() {
const priority = 'background';
const task1 = scheduler.postTask(doBackgroundWork, {priority});
const task2 = scheduler.postTask(doMoreBackgroundWork, {priority});
return Promise.all([task1, task2]);
}
function inputHandler() {
requestAnimationFrame(() => {
updateUI();
// Don't block the frame. See also scheduler.render().
scheduler.postTask(processInput, {priority: 'user-blocking'});
});
}
Similar to how fetch()
and other async APIs can be controlled with an AbortController
, tasks can
be controlled with a TaskController
by passing its TaskSignal
to scheduler.postTask()
. Like AbortSignal
, TaskSignal
can be used to abort tasks (a
TaskSignal
is an AbortSignal
). Additionally, TaskSignal
has a priority
property, which can
be changed with the associated controller. For more details, see the section on TaskSignal
and
TaskController
.
Example: Controlling tasks with a TaskSignal
.
function task() {
// ... do a bunch of work...
}
const controller = new TaskController();
const signal = controller.signal;
scheduler.postTask(task, {signal});
// ... later ...
// Change the priority, e.g. if the viewport content changed.
controller.setPriority('background');
// Abort all pending tasks associated with the signal.
// Note: this causes pending promises to be rejected.
controller.abort();
If signal and priority are both provided, the task will have a fixed priority and use the signal for
abort. Note that this is equivalent to passing
TaskSignal.any([signal], priority)
.
Example: Scheduling a task with signal and priority.
function task(signal) {
// Use the input signal for aborting, but with fixed 'background' priority.
scheduler.postTask(otherTask, {signal, priority: 'background'});
// The above is equivalent to and shorthand for:
const newSignal = TaskSignal.any([signal], {priority: 'background'});
scheduler.postTask(otherTask, {signal: newSignal});
}
Note: the signal
option for scheduler.postTask()
and other scheduler
APIs is specified as
an AbortSignal
, which means either a plain AbortSignal
or a TaskSignal
can be used, since
TaskSignal
inherits from AbortSignal
. This allows these APIs to work with existing code that
uses AbortSignal
. It also means a TaskSignal
can be passed as the signal
option to other APIs
that take an AbortSignal
.
function doSomethingWithAbortSignal(signal) {
// This will work if signal is either an AbortSignal or TaskSignal.
scheduler.postTask(task, {signal});
}
// ... somewhere else ...
const controller = new AbortController();
const signal = controller.signal;
fetch(someUrl, {signal});
doSomethingWithAbortSignal(signal);
WARNING: This section is out of date. The API no longer supports directly specifying the priority and abort behavior, but rather inherits this state from the current task. See https://wicg.github.io/scheduling-apis/#dom-scheduler-yield for the up-to-date API and behavior.
(See also the separate scheduler.yield()
explainer).
scheduler.yield()
can be used in any context to yield to the event loop by awaiting the promise it
returns. The task continuation — the code that runs as a
microtask when the returned promise is resolved — runs in a new browser task and gives the
browser a scheduling opportunity. Continuations are given a higher
priority by the UA, which helps minimize the latency penalty
for yielding.
Whereas scheduler.postTask()
can be used to break up long tasks on function boundaries by
scheduling chunks of work, scheduler.yield()
can be used to break up long tasks by inserting
yield points in functions.
Example: Inserting yield points.
async function task() {
doWork();
// Yield to the event loop and resume in a new browser task.
await scheduler.yield();
doMoreWork();
await scheduler.yield();
// ... and so on ...
}
// Schedule the long but yieldy task to run. scheduler.yield() can be used to
// break up long timers, long I/O callbacks, etc.
setTimeout(task, 100);
Similar to scheduler.postTask()
, developers can provide {signal, priority}
options to control
continuation scheduling and cancellation.
Example: Controlling continuations with priority and signal.
const controller = new TaskController({priority: 'background'});
async function task() {
doWork();
// Deprioritize the continuation, and reject the promise if
// the signal is aborted.
await scheduler.yield({signal: controller.signal}});
doMoreWork();
// Deprioritize the continuation, but don't ever abort it.
await scheduler.yield({priority: 'background'}});
...
}
If the yielding task was originally scheduled with scheduler.postTask()
and no options are passed
to scheduler.yield()
, then the current priority/signal will be inherited. This works
throughout the entire async task. Similarly, yielding within a requestIdleCallback
callback will
inherit 'background'
priority by default.
Inheritance can also be customized, for example to limit inheriting only the priority or abort
component of the current task's TaskSignal
.
If there isn't priority or signal to inherit, the default values are used (a non-abortable,
'user-visible'
continuation).
Example: Inheriting the task priority.
async function task() {
doWork();
// Inherit the current signal (priority and abort), which happens by default.
await scheduler.yield();
doMoreWork();
// Inherit only the current task's priority.
await scheduler.yield({priority: "inherit"}});
doMoreWork();
// Inherit the abort component and use a fixed priority.
await scheduler.yield({signal: "inherit", priority: "background"});
}
scheduler.postTask(task, {signal: theSignal});
Example: Inheriting priority in idle tasks.
requestIdleCallback(async (deadline) => {
while (notFinished()) {
workUntil(deadline);
// Continuations will run at `'background'` continuation priority.
await scheduler.yield();
}
});
Using scheduler.yield()
can be more ergonomic than alternatives, but it also solves a common
performance concern with yielding by prioritizing continuations. Developers are often hesitant
to yield because giving up the thread means other arbitrary code can run before the continuation is
scheduled. scheduler.yield()
solves this by giving continuations a higher priority within the
event loop. This means the UA might choose to process input or rendering before running a
continuation, but not other pending timers, for example.
Task and continuation priorities are ranked as follows:
'user-blocking' continuation > 'user-blocking' task >
'user-visible' continuation > 'user-visible' task >
'background' continuation > 'background' task
scheduler.render()
is similar to scheduler.yield()
, but the promise it returns is not resolved
until after the next rendering update if rendering is likely to happen. If the DOM is dirty or a
requestAnimationFrame
callback is pending and the page is visible, this means the promise it
returns won't be resolved until after the rendering steps next run (or the page is hidden). If
rendering is not expected, then the behavior matches scheduler.yield()
. In either case, the
promise is always resolved in a new task.
The main use case is to ensure pending DOM updates are shown to the user before continuing:
async function handleInput() {
showInitialResponse();
// Make sure the initial input response was rendered.
await scheduler.render();
continueHandlingInput();
}
This API takes the same options as scheduler.yield()
, which can be used to abort the continuation
and control its priority. Furthermore, prioritization (continuation scheduling) and inheritance work
the same as scheduler.yield()
.
scheduler.render()
informs the UA that a task is blocked on rendering, which is a signal that
rendering is important. While UAs typically have scheduling policies that prevent starvation of
rendering updates, an explicit signal can help optimize pages. For example, some sites may know
during loading exactly where the above-the-fold content ends, which is an ideal time to produce a
visual update. Providing an explicit signal to the UA at this point could help optimize page load,
if the UA's parser yields and runs a rendering update.
scheduler.wait()
is another "yieldy" API, similar to scheduler.yield()
and scheduler.render()
,
but used when execution should not resume immediately. The promise returned by scheduler.wait()
is
resolved after the provided timeout. This API can be used to pause execution of the current task for
some amount of time, e.g. to wait for a "ready signal" (polling) or to time-shift work.
window.addEventListener('load', async () => {
// Wait a second after load for things to settle.
await scheduler.wait(1000);
// ...
});
async function task() {
while (!ready()) {
await scheduler.wait(100);
}
// Carry on...
}
This API has the same optional parameters as scheduler.yield()
for controlling abort and priority,
including inheriting from the current task:
const controller = new TaskController({priority: 'background'});
async function task() {
// The continuation will have background priority, and it will be aborted if
// controller.signal is aborted.
await scheduler.wait(5000, {signal: "inherit"});
// Carry on...
}
scheduler.postTask(task, {signal: controller.signal});
The current thinking is that scheduler.wait()
continuations will be prioritized like
scheduler.postTask()
tasks and not like scheduler.yield()
continuations since there are
different latency expectations (wait()
adds latency by design). For continuations that need higher
priority, 'user-blocking'
priority can be used. This also enables using await scheduler.wait(0)
as a way to opt into yielding in a "friendlier" way, i.e. it doesn't try to regain control
immediately.
scheduler.wait()
is essentially a prioritized, promise-based setTimeout()
that doesn't take a
callback function. Developers often wrap setTimeout()
in a promise for this purpose.
Using scheduler.postTask({}, {delay, ...})
avoids the promise wrapping, but the proposed API is
simpler for the use case, more ergonomic for async code, and supports inheritance as well.
An extension of this API would be to enable waiting for things other than time, e.g. events. We plan to explore integrating this with observables, depending on how that work proceeds.
UAs can prioritize on a per-task
source basis; but such
decisions are difficult without explicit (userland) priority information. Integrating scheduler
priorities with other asynchronous APIs would give developers further control
over task ordering on their pages by creating a more complete prioritized task scheduling system.
fetch()
has a signal
option (an AbortSignal
) which is used to abort an ongoing fetch, similar
to the signal
option in scheduler
APIs. We propose extending this such that if the signal
provided is a TaskSignal
, the priority is used for event loop task scheduling.
Example: A 'user-blocking' fetch.
async function task() {
// Both of the promises below are resolved in 'user-blocking' tasks.
const prioritySignal = TaskSignal.any([], {priority: 'user-blocking'});
let response = await fetch(url, {signal: prioritySignal});
let data = await response.json();
}
This can be used to increase the priority such that fetch-related tasks won't be blocked by
'user-visible'
(default) continuations or tasks, or can be used to deprioritize fetches related to
background work.
Note that:
- This is separate from the
priority
option, which only controls network priority. - This proposal could be extended, for convenience, to include a
taskpriority
fetch option, similar to<script async>
below.
Async scripts are executed independently when ready, but the UA doesn't know the importance relative
to other queued work (e.g. other async scripts, tasks and continuations, etc.). Similar to
fetch()
, the tag supports a fetchpriority
attribute for network prioritization, but doesn't have a way to specify the execution priority. We
propose adding a taskpriority
attribute (a task priority) for this purpose.
Example: The taskpriority
<script>
attribute.
<!-- Ensure the script execution doesn't get in the way of other pending work. -->
<script async taskpriority="background" ...>
For non-async script tags, the taskpriority
attribute would have no effect.
MessageChannel
s are used to communicate between frames or workers, but are also used as a
scheduling mechanism (same-window case). In either case, the urgency of the messages are unknown to
the UA, which makes determining the event loop priority difficult. For example, some sites may rely
on frame <-> worker communication to drive the site, in which case it might be beneficial to
prioritize messages (and let the site triage them); but in other cases MessageChannel
is used to
replace setTimeout(,0)
, in which case always prioritizing is probably the wrong choice.
Like in other cases, we propose adding an option for prioritizing messages, in this case via a new
option in the MessageChannel
constructor:
// Messages sent between ports are scheduled at 'background' priority in the
// associated event loops.
const channel = new MessageChannel({priority: 'background'});
This section is left as future work.
(See also the original scheduler
explainer
and specification.)
scheduler
tasks and continuations have an associated priority, and they can be
aborted with an AbortSignal
. TaskSignal
— which inherits from AbortSignal
—
encapsulates this state. TaskController
is used to signal a change in this state.
A TaskController
is an AbortController
(inheritance) with the additional capability of changing
its signal's (TaskSignal
) priority. This can be used to dynamically reprioritize pending tasks
associated with the signal.
These primitives are used to control scheduler
tasks through the signal
option in the APIs that
follow.
Example: Creating a TaskController.
const controller = new TaskController({priority: 'background'});
// `signal` can be passed to `scheduler` APIs and other AbortSignal-accepting APIs.
const signal = controller.signal;
console.log(signal.priority); // 'background'
console.log(signal instanceof AbortSignal); // true
console.log(signal.aborted); // false
Example: Signaling 'prioritychange' and 'abort'.
const controller = new TaskController({priority: 'background'});
const signal = controller.signal;
// TaskSignal fires 'prioritychange' events when the priority changes.
signal.addEventListener('prioritychange', handler);
controller.setPriority('user-visible');
console.log(signal.priority); // 'user-visible'
// TaskController can abort the associated TaskSignal.
controller.abort();
console.log(signal.aborted); // true
(See also the TaskSignal.any()
explainer
and specification.)
AbortSignal.any()
creates an AbortSignal
that is aborted when any of the signals passed to it are aborted. We call this a dependent signal
since it is dependent on other signals for its abort state.
TaskSignal.any()
is a specialization
of this (inherited) method. It returns a TaskSignal
which is similarly aborted when any of the
signals passed to it are aborted, but additionally it has priority, which by default is
"user-visible" but can be customized — either to a fixed priority or a dynamic priority based
on another TaskSignal
.
Summarizing, compared to AbortSignal.any()
, TaskSignal.any()
:
- Returns a
TaskSignal
instead of anAbortSignal
- Has the same behavior for abort, i.e. it is dependent on the input signals for its abort state
- Also initializes the priority component of the signal, to either a fixed priority or a dynamic
priority based on an input
TaskSignal
(meaning it changes as the input signal changes)
Example: TaskSignal.any()
with default priority.
// The following behaves identical to AbortSignal.any([signal1, signal2]), but
// the signal returned is a TaskSignal with default priority.
const signal = TaskSignal.any([signal1, signal2]);
console.log(signal instanceof TaskSignal); // true
console.log(signal.priority); // 'user-visible' (default)
Example: TaskSignal.any()
with a fixed priority.
// The resulting signal can also be created with a fixed priority:
const signal = TaskSignal.any([signal1, signal2], {priority: 'background'});
console.log(signal.priority); // 'background'
Example: TaskSignal.any()
with a dynamic priority.
// Here, `signal` is dependent on `controller.signal` for priority.
const controller = new TaskController();
const sourceSignal = controller.signal;
const signal = TaskSignal.any([signal1, sourceSignal], {priority: sourceSignal});
console.log(signal.priority); // 'user-visible'
controller.setPriority('background');
console.log(signal.priority); // 'background'
TaskSignal.any()
provides developers with a lot of flexibility about how tasks are scheduled and
which other signals should affect a task or group of tasks.
scheduler.currentTaskSignal
returns the current task's signal, which is the TaskSignal
used by
the "yieldy APIs" (scheduler.yield()
et al.) for signal inheritance. Exposing this signal enables
using it to schedule related work or combining it with other signals without needing to pass the
signal through every function along the way.
Example: reading the current task's priority.
function task() {
console.log(scheduler.currentTaskSignal.priority); // 'background'
}
scheduler.postTask(task, {priority: 'background'});
Example: combining signals with the current task's signal.
async function task() {
// Subtasks should be aborted if this task's signal is aborted, but can
// separately be aborted by this controller.
const controller = new AbortController();
const signal = TaskSignal.any(
[controller.signal, scheduler.currentTaskSignal],
{priority: scheduler.currentTaskSignal});
scheduler.postTask(subtask1, {signal});
scheduler.postTask(subtask2, {signal});
// ...
}
scheduler.postTask(task, {signal: someSignal});
These APIs are fundamentally connected through shared priority and signals, and used together they provide developers over more control of scheduling on their pages.
Example: Handling a click. This uses a few scheduling primitives to handle a click event, using
a TaskSignal
to prioritize continuations and fetches.
// Global controller used to control app state (assume it's used elsewhere).
let appController = new AbortController();
// TaskController used for only for clicks.
let clickController;
button.addEventListener('click', async () => {
// Abort a previous click.
if (clickController) {
clickController.abort();
}
clickController = new AbortController();
// Create a user-blocking task signal dependent on both controllers.
const signals = [appController.signal, clickController.signal];
const signal = TaskSignal.any(signals, {priority: 'user-blocking'});
showSpinner();
await scheduler.render({signal});
// Handle the click.
const start = performance.now();
const signal = scheduler.currentTaskSignal;
const res = await fetch(url, {signal});
let data = await res.json();
// process() could be a yieldy task.
data = await process(data);
// Something like this could used to start a delayed UI update.
const elapsed = performance.now() - start;
if (elapsed < 1000) {
await scheduler.wait(1000 - elapsed, {signal});
}
// Display the summary and wait for it to be rendered.
displaySummary(data);
await scheduler.render({signal});
// Display the rest. This could use render() or yield() to further break
// up the task.
displayDetails(data);
});
TaskSignal
inherits from AbortSignal
to simplify sharing signals between signal-accepting APIs,
rather than creating a separate PrioritySignal
See this
issue for more discussion.
See this section from the original explainer.
See this section from the original explainer.
There are two common patterns developers can use to get similar behavior, with slightly different scheduling behavior.
Double-rAF. This approach uses a nested requestAnimationFrame()
to ensure the work starts
after the next frame.
function handleInput() {
showInitialResponse();
requestAnimationFrame(() => {
requestAnimationFrame(continueHandlingInput);
});
}
rAF + continuation: This approach also ensure work starts after the next frame, but by scheduling a non-rAF-aligned continuation from within rAF.
function handleInput() {
showInitialResponse();
requestAnimationFrame(() => {
// Continue ASAP. Without `scheduler`, setTimeout() can be used.
scheduler.postTask(continueHandlingInput, {priority: 'user-blocking'});
});
}
Note that continuing in the initial requestAnimationFrame
can lead to poor responsiveness since
the rAF handler blocks the frame. scheduler.render()
avoids this problem and is more ergonomic for
async code. It is also more efficient in that it doesn't cause a frame if one isn't needed and,
it's more robust in that it works regardless of page visibility (which can be important if loading a
page in the background, for example).
The previously proposed
requestPostAnimationFrame()
is another alternative. It doesn't have frame-blocking problem, but scheduler.render()
provides a
scheduling opportunity since the continuation is in a separate task, which is better for
responsiveness.
The main alternative is to prioritize individual messages, either on self.postMessage
or on a
MessageChannel
. But this approach would require triaging messages on the receiving side to put
them in the right queues, which would complicate the implementation and raises efficiency concerns.
Many thanks for valuable feedback and advice from anniesullie, clelland, mmocny, philipwalton, and tdresser.