Effection: for when async/await is not enough

Jonas Niklas

October 26, 2021

Everyone who has built a complex system in JavaScript has hit a critical moment where the illusion of control comes crashing down. Those are the moments when there are dozens (maybe hundreds) of concurrent processes running and it feels like you've lost control over them. Maybe a promise handler or callback executes even though it is no longer relevant and messes up the state of the system. Or perhaps an error disappears into the void or a socket is not closed when it should be.

The problem with concurrent processes in JavaScript is that it is fundamentally unstructured. Anyone can add a callback, or await a promise at any time, and once that task is created, it has an unconstrained lifetime. If the async function or the callback is no longer relevant, how do you cancel it? Worse yet, if you're performing multiple callbacks or promises or awaits, are you sure you're dealing correctly with errors in all of them?

Introducing Effection

The solution is to adopt the ideas of structured concurrency and apply them to JavaScript. The core idea of structured concurrency is that the lifetime of a task is always constrained. This means that a task must not outlive its parent, or in other words, it is impossible to create a task that runs forever. This might seem like an obvious constraint, but it is important to note that this is very much not the case with the core concurrency primitives available in JavaScript.

Effection is an Open Source concurrency framework that replaces async/await with a more structured way of writing code. Let's look at how this applies to async/await code:

async function fetchUser(id) {
  let response = await fetch(`/users/${id}`);
  return await response.json();
}

There is nothing special about this async function. And this is what happens when we call this function from another function:

async function fetchSomeUsers() {
  let userOne = fetchUser(1);
  let userTwo = fetchUser(2);
  return {
    one: await userOne,
    two: await userTwo,
  }
}

Here we're calling fetchUser twice, and then awaiting both calls. This will cause both calls to fetchUser to execute concurrently. We could also implement this using Promise.all, which would behave similarly and is subject to the exact same pitfalls:

async function fetchSomeUsers() {
  let userOne = fetchUser(1);
  let userTwo = fetchUser(2);
  return Promise.all([userOne, userTwo]);
}

It looks like the fetchSomeUsers function is nice and self-contained, but in fact it isn't. We start fetching two users, but both of those fetches are in no way tied to the fetchSomeUsers function. They run in the background, and no matter what happens within fetchSomeUsers, they just keep running. Potentially they could run forever; that's what we mean when we say that their lifetime is unconstrained.

For example, there is nothing stopping us from doing something silly like this:

async function fetchUser(id) {
  setTimeout(() => {
    console.log("I'm still running, lol!");
  }, 1000000);
  let response = await fetch(`/users/${id}`);
  return await response.json();
}

Even ages after the fetchUser function has finished, it will still print something to the console. We can't close the existing loopholes (such as setTimeout), but as long as you are writing idiomatic Effection code, something like the above just cannot happen.

Let's look at this example again – but this time using Effection:

import { spawn, fetch } from 'effection';

function* fetchUser(id) {
  let response = yield fetch(`/users/${id}`);
  return yield response.json();
}

function* fetchSomeUsers() {
  let userOne = yield spawn(fetchUser(1));
  let userTwo = yield spawn(fetchUser(2));
  return {
    one: yield userOne,
    two: yield userTwo,
  }
}

It's not that different from before: Effection uses generator functions instead of async functions, and there is something going on with spawn, but other than that it looks pretty much the same.

However, the way that this function runs is very different. When we spawn a task with Effection, its lifetime is tied to the current task, which means that fetchUser(1) and fetchUser(2) cannot ever outlive fetchSomeUsers. Moreover, no task that fetchUser spawns can outlive fetchUser either. Effection tasks ensure that everything that happens within the task stays within the task.

Using Effection

This might seem like a trivial outcome but the implications are profound. For example, Effection ships with an operation called withTimeout, which adds a time limit to any task. If the time limit is exceeded, an error is thrown.

That means we can now do this:

import { withTimeout } from 'effection';

function* fetchSomeUsersWithTimeout() {
  return yield withTimeout(2000, fetchSomeUsers());
}

Right now in JavaScript something like this is pretty much impossible to implement with promises. There is just no way to know what fetchSomeUsers does internally, and there is no way to know whether we really can abort all of it. Trying to write a function like this is at best unsafe and at worst impossible.

With Effection it all just works. We know that nothing can escape fetchSomeUsers and we know that everything that fetchSomeUsers does will be canceled if we cancel fetchSomeUsers itself.

Now, imagine that our async/await fetchSomeUsers fails for some reason:

async function fetchSomeUsers() {
  let userOne = fetchUser(1);
  let userTwo = fetchUser(2);
  throw new Error('boom');
  return {
    one: await userOne,
    two: await userTwo,
  }
}

What will happen in this case? fetchUser(1) and fetchUser(2) will happily keep running, even though the fetchSomeUsers function which initially called them has already failed.

fetchSomeUsers timing with async/await

This can't happen in Effection. Because given that fetchUser(1) and fetchUser(2) are scoped to their parent function, they will be terminated when fetchSomeUsers enters into an error state.

fetchSomeUsers timing with effection

And this is the power of Effection's structured concurrency: it allows us to build abstractions that would otherwise be impossible to construct. We think it is a fundamentally better way to write JavaScript.

Going further

There is much more to Effection than what we've shown here. Effection is not only a framework that allows you to write rock-solid code, but it also gives you amazing insights into your application through our experimental inspector. If you're curious to learn more, this guide explains in greater detail how to use Effection, and the API Reference contains a complete reference of all methods and types that Effection provides.

We are a small but dedicated team at Frontside that likes to tackle challenging problems with an ambitious solution. There is boundless work to be done, and boundless ideas to explore. Do you want to join us on this journey? Come hang out with us on discord, where we stream our work and are always interested in discussing where to go next.

Subscribe to our DX newsletter

Receive a monthly curation of resources about testing, design systems, CI/CD, and anything that makes developing at scale easier.