ember-concurrency

or: How I Learned to Stop Worrying and Love the Task

December 14, 2016

Working within the Ember community is exciting due to the novelty of the framework, but can at times be daunting because we sail into uncharted waters. Over the last year, our team has been building the Centralized Release Tool (CRT), a large central app that serves as LinkedIn developers’ one-stop-shop for automating and tracking code changes as they go through the deployment pipeline. Along the way, we have run into many of the issues plaguing interactive, data-driven Ember applications. We have run the gamut of use cases for data fetching, with dashboards pulling data from multiple sources, filter-based routes that restart model queries for each new parameter, and periodic data refreshes.

Several months ago, we discovered and began to adopt Alex Matchneer’s fantastic ember-concurrency, an Ember add-on that uses ES6 generator functions to reduce the boilerplate around concurrency while adding structure to complex asynchronous code. Our experience has been overwhelmingly positive, and we’ve learned some things along the way as well.

Tackling typical challenges

One of the main pages in CRT contains a component that displays all commits in the last 24 hours for a given LinkedIn product. Since project owners and contributors monitor the dashboard in real time, we needed to refresh the commits data every 30 seconds (Fig. 1).

  • ember-concurrency-reload-every-30-seconds

Fig. 1: A periodic data reload every 30 seconds

To manage periodic tasks, we initially wrote a “timer” service that ran a function on an interval. Unfortunately, this hefty solution had side effects. On every reload, we build a list of {{commit}} components, each of which contains a modal that can be opened when clicking the blue icon at the top right of the component. Every time the data updated, the new commit components would flush any open modal and make it disappear. To work around the problem, we added functionality to stop the timer on the modal's "open" event and restart it on "close." We then realized that the timer would keep going after we had left the page and try to update a nonexistent this.get(‘model’), so we stopped the timer on component destroy. We needed flags like isModalOpen and hooks like willDestroyElement to maintain control over our application state. All of a sudden, what seemed like a trivial feature had ballooned to 80 lines of unwieldy, error-prone code:

Enter ember-concurrency

The add-on is best explained by Alex Matchneer himself, but the core concepts are as follows.

Promise-based operations can be written as generator functions, an ES6 feature that allows for stepping in and out of a function, controlling the execution like a user would control a debugger. A generator function must explicitly be told to execute until its next yield expression (think of it as a breakpoint). Passing a promise to a yield statement instructs the function to wait until the promise is resolved before resuming execution. This linear flow encourages “structured programming,” which dictates that at any point in an asynchronously run block of code, you should be aware of the state of your components and capable of canceling future operations.

As Ember and JavaScript as a whole begin to move towards this pattern, we can draw out the progression of handling asynchrony in JavaScript:

ember-concurrency tasks wrap around generator functions to leverage their flexibility and internally manage their execution. The key feature of a task is the ability to cancel a function, which developers have long yearned for with no satisfactory solution. Most notably, the task API can:

  • “Cancel” a function: the task simply stops instructing the generator function to step to its next yield.
  • “Restart” a function: if it is currently being executed, the task will “cancel” it and perform it again, from the start.
  • Manage concurrent function execution: like a traffic guard, ember-concurrency controls the execution of multiple tasks being performed from different places. Developers can ask it to limit the number of times the same function can be run at the same time, maintain a queue to run them one after another, and much more.
  • Manage control flow substates: developers have out-of-the-box, free access to flags and properties like isRunning, isIdle, lastSuccessful, etc.
  • Automatically cancel tasks that are bound to a component: no need for the willDestroyElement hook—it is safe to assume that the UI code for trailing network requests will not be executed long after you have left the page.

When we first discovered the add-on, the possibilities were obvious. We could use restartable tasks to power our filters interface that reloads the model every time the user introduces a new parameter (see Fig. 2). We could build an array of task instances to generate a dashboard of panels making separate requests.

  • ember-concurrency-2-adding-task-restart

Fig. 2: Adding my-product-frontend will cancel the initial unfulfilled my-product-backend task and restarts it with my-product-frontend

Let’s get back to the task at hand (no pun intended): getting rid of our clunky timer service. Luckily, ember-concurrency gives us some explicit cancellation methods and a handy timeout function, which simulates a promise that resolves after a specified amount of time. We can get  rid of our timer service and rewrite the component:

The two yield statements allow us to anchor the task around the timeout and the data reload. The infinite while just means that the function keeps running until ember-concurrency decides to cancel it, which would occur if the component is destroyed, or if we explicitly call the cancelReloadTask action. That’s it! No timer service, no binding callback functions to the component, no lifecycle hooks. The logic is concise and linear.

This problem serves as our proof of concept; it shows us that ember-concurrency is a viable alternative to the clunky promises peppered throughout our app. We felt the freedom to use it to manage state and asynchronous operations anywhere.

Re-architecting the model

Armed with ember-concurrency, we decided to attack the backbone of a typical Ember route: its model hook. When using ember-data, Ember models expect to receive either records or a promise that resolves to one or more records. When activated, a route waits to resolve the promise to change the URL and display its template, which can lead to a delayed experience for slow data requests. Unfortunately, Ember’s approach to both loading and error substates is not very flexible: while fetching model data, Ember looks for a specific loading route, temporarily displays it, then transitions back to the original template with model data. This approach was problematic for a couple of reasons:

  • With the model hook expecting one promise to be returned, fetching multiple models is only possible via an RSVP.Promise.hash which resolves when all parameter promises have resolved. In a dashboard where each panel requires its own network request, the hash would entirely block our UI until the slowest promise has finally resolved (Fig. 3).
  • ember-concurrency-separate-data-wrapped-in-promise

Fig. 3: Three separate data requests wrapped in a RSVP.Promise.hash

  • Our team’s approach to building pages consists of using route-level templates that provide a single location to define the structure of the page. These templates contain  block-formatted child components that behave like extensions of HTML elements; they wrap around content provided by the parent template and only expose component-specific properties. As a result, we can pass model properties into specific components without having to pass them down through the entire component tree.  This means we spend less time sifting through a maze of files to find the markup and/or functions we need. Splitting “loading templates” out into separate files does not suit our needs, as we want the flexibility to control substates in our main template.

We observed that the model hook hangs on transitions and looks for loading templates before it resolves its promises. To take more control over this rigid process, our hooks now return simple objects containing ember-concurrency task instances:

To Ember, the model resolves instantly and the task API gives us all the information necessary for us to control the ternary logic in our template:

Though this is a basic example, it illustrates the clarity of templates that control every step of the data fetching process with task instances. With more complex UIs, we can add more information to the model object. In our product list route, we allow users to select several products to display on the same page. Instead of wrapping requests for each product’s model inside an RSVP.Promise.all and blocking the UI until every product is retrieved, we can build an array of task instances corresponding to the products specified in the query parameters:

In our template, we now have access to an array of isolated task instances, with full control over their substates:

Relaying all task state management to ember-concurrency allows us to segment our UI into various components that each depend on one task. No component needs to know about other components' tasks, and we can display our UI progressively instead of waiting to show the full UI (Fig. 4).

  • ember-concurrency-loading-without-blocking

Fig. 4: Loading data progressively without blocking UI

The simplicity of the task API has allowed us to make UI enhancements beyond the scope of traditional Ember applications. For pages where the data is likely to change a lot, like our filters interface, we store two task instances in our model, one for the current data request, and one for the last performed data request for this same model. With these two task instances, we can display stored data while we update the model with new data, rather than always showing an obstructive loading indicator. Even though ember-data can show data from the store while it executes a "background reload," it provides no context around this data update, such as a flag to specify that an update is occurring or a timestamp of the last time we retrieved data from our backend. If the data has changed between the two requests, the UI jumps with no visual explanation. With our two-task approach, we can determine if old, stored data is changing and show an “updating” pill; ember-concurrency's task storage system lets us identify the last completed tasks and keep track of contextual information. The result is a clear interface that can explicitly signal updates (Fig. 5).

  • ember-concurrency-storing-successful-tasks

Fig. 5: By storing previously successful tasks, we can show data updates. Here, we leave a route and re-enter it to trigger a data update.

Benefits for both parties in a web app

A large web app is successful if users enjoy navigating it and if developers enjoy building it. While ember-concurrency was written with the intention of reducing boilerplate code and enforcing logical boundaries, our full-scale adoption of the add-on has made the user experience much smoother. We have built an app that demonstrates the benefits of single-page applications: route transitions feel extremely speedy because the model hook returns simple objects without pausing to resolve promises, and the user receives instant visual feedback about network operations in response to clicks. Pages that retrieve data from multiple sources can display it in a progressive way and minimize idle time where the user cannot interact with the page. Responsive filter-based interfaces reflect data updates in a clear and dynamic way.

Many JavaScript developers, myself included, can relate to the frustration of staring at a promise chain or callback functions to understand a particular execution flow. JavaScript’s powerful concurrency model and event loop become very cumbersome to manage in complex apps, where multiple components and routes multiply the unpredictability of user interaction and network latency.

ember-concurrency's mission to improve developer experience has been a great success. When futuristic ES6 features like generator functions were introduced, many developers nodded their head and shrugged; it seemed like a different way to execute the same asynchronous operations, with no obvious breakthrough implementation. A few years later, ember-concurrency has established itself as one of the most dynamic applications of generator functions. We now write readable code without convoluted promise callbacks. The browser uses an event loop to handle asynchrony, but our brains have an easier time understanding logic that looks synchronous. Like other modern JavaScript features such as async/await, generator functions abstract the complexity of the run loop into structured, linear code that mimics a flat timeline. Developers like us are the big winners, as our code is more readable and maintainable.

A glimpse into the future of JavaScript

Tasks reflect a shift from the philosophy of promise-based frameworks: whereas promises ensure that specific code will be run, the unpredictability of the web is such that making future guarantees behind time-based operations is dangerous. Tasks that are bound to components offer a much more reassuring guarantee, assuring us that code will be executed as long as the component is active and the task is not explicitly canceled. We are much more comfortable and engaged writing code because we know that an add-on is abstracting out the boilerplate logic.

ember-concurrency is still not perfect, and our work in internal tools has shielded us from the few downsides of using it. By assuming that our users use modern browsers on a fast connection, we could afford to take a flyer on a library that initially required heavy Babel polyfills (though this is no longer the case) and did not provide easy server-side rendering support with Ember FastBoot. Several months later, the add-on feels more mature, and we have been so happy with it that we have entirely eradicated promises from our code base. By going at the heart of issues like developer convenience and concurrency management, it is one of the best concrete applications of futuristic JavaScript features. Give it a shot, we guarantee it’ll change the way you write Ember.

Acknowledgements

Warm thanks to Alex Matchneer for building this truly game-changing add-on and helping me understand the motivations behind it, to Josh Lawrence for his willingness to take calculated risks in our big project, and to the rest of the Centralized Release Tool team for their tremendous work on highlighting the power of ember-concurrency.

Topics