Clearing Async Nests with ember-concurrency
I’m contractually obligated to include a bird reference in the title.
At Iora Health, our mission is to restore humanity to health care. One of the ways we serve our mission is by building a collaborative care platform centered around caring for our patients, rather than using traditional enterprise health systems that are built around administrative tasks like billing and coding. Because our namesake is a bird and because we like fun names, we call our platform Chirp.
Chirp serves many functions. It’s an electronic health records system. It’s a scheduling platform. It’s a practice management platform. It even facilitates some billing and coding processes (gasp!). Chirp is complex.
Today we’re focused on how ember-concurrency helps manage complexity and allows us to stay focused on building features that serve our mission and help our teams care for patients.
Life before ember-concurrency
The year was 2016. Half the team was focused on building a scheduling application for our practices in Ember. There was data flying everywhere! Schedules! Calendar events! Staff members! We were stuck wrangling Promises. I wrote .then
so many times, I wore out the letter t
on two keyboards. It was a nightmare.
Like many Ember users, we use Ember-Data for our data loading and management needs, so a lot of our code looked something like this:
This is a snippet of our appointment-form component, circa 2016. You’ll notice that besides doing a save of the new appointment record we’ve created, we’re also updating a few bits of state on the component: isAppointmentSaving
, appointmentFormErrors
, and some properties on our calendar
service.
In the template for the component, we used isAppointmentSaving
for controlling form UI state. First, it would disable the submit button to prevent duplicate submissions. Then, it would control the display of a loading state on the appointment form, so the user knew we were altering time and space. Likewise, with the appointmentFormErrors
, we managed and cleared them out manually.
Unfortunately, the manual form state tracking was error prone. What would happen if we forgot to reset isAppointmentSaving
in the error state? What happens if they close the form or navigate away while it’s saving? The answer: bad things. Users could get stuck in the form, with the spinner spinning away until they gave up and reloaded the page. If a user navigated away, we’d get a nice big stack trace in our error reporting tool. We couldn’t do anything about it, and it simply told us “the component is gone.”
What is ember-concurrency?
ember-concurrency
is a popular add-on for Ember, which provides a small but powerful set of tools for managing asynchronous work.
ember-concurrency
provides a task-based approach for managing asynchronous work. At a high-level, it makes “Cancel” buttons, debouncing, throttling, secondary data loading, and other complicated things uncomplicated to implement. A Task
is a Promise
-like construct which gives you access to execution state, such as whether the task is running, is idle, or has failed and allows for setting constraints on how many instances of the task can be running. Manually tracking wasSubmitButtonClicked
or isLoading
and hoping that all bases are covered is no longer required. All your base are belong to ember-concurrency
now. Also, unlike Promises, they can be broken, e.g. partyTask.cancel("sorry, just crashed on my couch. #Netflix")
.
ember-concurrency
tasks are also aware of the lifecycle of objects to which they’re attached. For tasks on a component, this means the tasks know when the component is removed from the page and will cancel themselves. This ensures the asynchronous work you have running doesn’t blow-up when it can no longer access a component that is been destroyed. If you need to make a financial argument, experts suggest that this facet alone will save you an estimated $100 million dollars* per year in JavaScript error logging storage costs. Wow, talk about a good deal!
There are a lot of amazing things that are made possible by ember-concurrency
, but unfortunately I cannot describe them all in this post. Please check out the wonderful documentation site, which features in-depth explanations, fancy animations, and useful examples to get you started. You can also read Alex Matchneer’s post about it or watch his EmberConf talk.
(*Actual results may vary.)
How ember-concurrency improved things
In early 2017, we brought in Mike North to give the team a few days of Ember training. We’d recently committed to rewriting our main application from Backbone to Ember and were looking to train ourselves up, so that we had the tools and tricks to succeed. (Spoiler: it worked.) One of the most important power-ups Mike introduced us to was ember-concurrency
.
We went over some of our scheduling application’s code and Mike showed us a few examples of how we could refactor our Promise nests using ember-concurrency
. The idea was to manage only what we need to manage, and leverage as much derived state as possible to reduce the potential for mistakes. The whole room cheered. We hoisted Mike up onto our shoulders and went to get pizza.
When we got back to working on our scheduling application, we were able to refactor away a lot of the manual state tracking we were doing and use the wonderful derived state from ember-concurrency
instead. We went from being professional Promise Wranglers to becoming Task Tango-ers, which sounds far more delightful. Here’s what our appointment form code looks like on Tasks:
This refactoring was cool. We’re certainly managing less state ourselves, but it doesn’t really show off how powerful ember-concurrency
can be. To get deeper into ember-concurrency
, we need to look at a more difficult problem, one that’s more hairy to solve without ember-concurrency
.
Building for resiliency and recovery
Let’s say you’re a small, but growing healthcare delivery company. You’ve got your area of focus, and you’re really good at it, but you need to provide support for something very complicated — prescriptions management — that cannot be done with the resources you currently have. (Maybe you’ll get there one day. I believe in you!) You would likely engage a vendor that provides the service you’re after. However, unless you’re a really good negotiator or hold the vendor’s mascot hostage, vendors work with lots of companies, not just your own, and cannot guarantee 100% reliability. Their systems are out of your control and sometimes they experience issues. Sometimes they experience issues more frequently than you’d like.
No one can predict these things but it’s important to be prepared by building in resiliency and recovery opportunities into the UI. Topics like fault tolerance are usually discussed at the systems and network level, but there’s a place for them at the UI level too.
At Iora, our colleagues load our application and get a big hunk of JavaScript over the wire. Then, their browser parses and executes the JavaScript. There’s a non-trivial cost to loading that all up again, so we don’t want to force them to reload the page if we don’t need to. If a user clicks a button which makes a call to a degraded or unavailable service and the application crashes, that’s no fun. The user might even think it was their fault. And that’s terrible.
Handling the unhappy path
Imagine you’re using Promises, and you need to provide a level of resiliency or fault tolerance to an operation.
- You need to be able to tell the user when something went wrong.
- The user should also be given the option to retry the operation.
- Later, you’d like to be able to retry the operation automatically without any interaction from the user. You’d like to target the automatic retries on errors consistent with transient failures the vendor is known to experience.
How would you do it?
A lot of people would look at such a problem and come to the very reasonable conclusion that it’s not worth the effort. Others might tackle it with similar but domain-tailored solutions, which would then need to be maintained.
An error occurred, we’re sorry
Let’s look at the first requirement related to telling the user what went wrong. This is easy with ember-concurrency
, and in fact there are a few ways to do it: you could check the isError
property on the latest task instance in your template to show a message or you could listen for the errored
lifecycle event on the task and trigger a flash message. Here’s how we use the isError
property and the derived error state to show when there’s a problem with our prescriptions service:
This is a quick win with ember-concurrency
. We don’t have to manually track if the request failed ourselves or why it failed. It’s all attached to the task instance, and we don’t have to think about resetting any state.
Enabling recovery
Now, what about letting the user retry a request? This is real easy with ember-concurrency
too. Here’s how we added a retry button:
Admittedly, you could do this with a regular action as well. But without ember-concurrency
, you’d need to manually manage that error state and probably incorporate some sort of state reset within the retry button click. Yuck.
Extending ember-concurrency to enable automatic retries
ember-concurrency
provides a really powerful level of primitives, which help reduce a lot of manual state tracking and boilerplate for common asynchronous UI patterns. However, ember-concurrency
is not immune to boilerplate when it comes to more complex patterns.
Take for example, a long-running, looping background task, in which we want to try and recover from a certain number of errors before we give up. We can solve a lot of this with the ember-concurrency
primitives we have, but end up with a lot of manual state tracking for things like “how many times has this task failed?” In our application, we have a set of tasks like this for maintaining our user’s session while continuously using the application. Here’s how we did this using only regular ember-concurrency
:
Taking a look at the keepAlivePollingTask
, there is a lot going on in there. Most of the complexity is surrounding tracking state for how many requests have failed, how many have run, and adapting what happens next based on that.
The above approach does result in Working Software™. It tracks that state, shows different notifications to the user when we’re trying to reconnect them, and alerts them when they might get automatically logged-out due to inactivity. However, it is a very rigid implementation. The retry and failure tracking logic is as-is. It’s easy enough to reduce or increase the amount of failures allowed, or change the interval between checks. If we wanted to do something more complicated, like implementing exponential backoff, we’d need to revamp the state tracking and restructure some things, rather than swapping something out or changing a setting.
ember-concurrency-retryable
That’s where ember-concurrency-retryable
comes in. It’s an Ember add-on that hooks into ember-concurrency
to provide support for automatic retrying of tasks. (Disclosure: I am the author of ember-concurrency-retryable
, and I alone am responsible for its faults.)
Implemented as a new task modifier (like .drop()
or .restartable()
), you can enable retryable behavior for any tasks and delegate control to reusable policies for how the task will be retried. Below is a simple example of how it can be added to tasks:
If at first you don’t succeed, retry, retry, retry, retry again
Refactoring the session service using ember-concurrency-retryable
, we’re able to get rid of a lot of the inner complexity in the keepAlivePolling
task. In addition, we’re able to leverage the events interface of ember-concurrency
and ember-concurrency-retryable
to react to the task, rather than complicate our task function. Because ember-concurrency-retryable
is controlling the retry behavior, we can tweak and swap out the retry policy with something that fits with some future requirements, without retooling the session service code.
Below is the refactored session service. As can be seen on lines 26–46, the keepAlivePolling
task is much less complex now that we’re not tracking as much state ourselves. Instead, the logout and disconnected handlers are called in response to events according to the lifecycle
Our session service still has some oddities related to legacy behavior we need to iron out. However, it’s a much more understandable bit of code, now that we’ve been able to leverage more advanced ember-concurrency
features like events, waitForProperty
, and race
, and use ember-concurrency-retryable
to refactor away the complexity around retrying.
Conclusion
As more application complexity moves to the frontend, having a robust way to manage asynchronous application behavior and the concurrency problems inherent in the browser is imperative in order to provide a consistent and reliable user experience. Promises and async functions are wonderful primitives, but they’re just that: primitives. A toolkit like ember-concurrency
and power-ups like ember-concurrency-retryable
provide the control you need to design interactions you can reason about, so you can stay focused on the problems you need to solve for your colleagues, users, customers, and, in our case, patients. If you’re still out there in the Async Sea, drowning in Promises, I strongly encourage you to join us aboard the USS Ember-Concurrency. We’re shipping out full-steam ahead! (I couldn’t think of anymore bird puns.)