Skip to content

Brady Mackey

Asynchronous Abstractions in Vanilla Javascript

Javascript, Asynchronous, Coding3 min read

"Theres a library for that"

We have all heard it. I often find myself reaching for a library to solve my problem as soon as I run into my first hurdle. But I think its worth exploring what happens when trying to develop your own abstractions. Even if you eventually solve your problem with a library, getting additional context and experience before you dive into library code is always a good thing

This is my time spent exploring two abstractions: an interruptable request and a cancellable request.

Basis

Lets say you have an async function that will make a request for you and return the JSON results.

1const makeRequest = async (url, params) => {
2 var fullUrl = url+paramterize(params);
3 var newRequest = new Request(fullUrl);
4 var response = await fetch(newRequest);
5 var result = await response.json();
6 return result;
7}

But suppose we don't just want to make a single request. What if I wanted to be able to make a request, and then interrupt it later?

First Example: Interruptible Request

Calls to a server for information is one of the slowest things that happen on a website. Lets say your app makes lots of requests to a server for information, but only really cares about the latest request. When another request comes in, you would like to be able to cancel the old request and kick off the new one.

Lets start with a generator function that will return our "requester"

1const interruptibleRequestGen = () => {
2 let currentRequest = null;
3 const controller = new AbortController();
4 const signal = controller.signal;
5}

The AbortController is not fully supported by all browsers, but in this case we will use it for the purposes of exploring how we could approach cancelling these Promises here.

Lets flush out the rest of the implementation.

1const interruptibleRequestGen = () => {
2 let currentRequest = null;
3 const controller = new AbortController();
4 const signal = controller.signal;
5 return async function makeRequest(url, params) {
6 if (currentRequest) {
7 controller.abort();
8 }
9
10 const fullUrl = url + paramterize(params);
11 currentRequest = new Request(fullUrl, { signal });
12 const response = await fetch(currentRequest);
13 const result = await response.json();
14
15 currentRequest = null;
16 return result;
17 }
18}

This generator wraps makeRequest function up with a closure containing our AbortController. Additional requests will be aborted if they are ongoing when another request comes in. We can invoke th

1const get_one = interruptibleRequestGen();
2const get_two = interruptibleRequestGen();
3const get_three = interruptibleRequestGen();
4
5get_one("http://google.com", {}).then((result) => console.log(result))
6get_one("http://facebook.com", { name: "Brady" }).then((result) => console.log(result))
7get_two("https://nodejs.org", { version: "latest" }).then((result) => console.log(result))
8get_three("http:/bradymackey.com", {}).then((result) => console.log(result))

In this example, three different "interruptible" requesters are created. The first request get_one will be interrupted when the second comes in. get_two and get_three are different functions, so they a will finish without interrupting each other or get_one.

Controlled Request

Lets say you are building an infinite loading list. As the user scrolls to the bottom of the page, you detect they are close to the bottom and load more items.

The issue is that as the user scolls to the bottom, they might be scrolling fast. Depending on how you set up your event handlers, that function might try to execute many times, and if the results are appended to the DOM each time you have a serious bug.

Lets approach our request from the opposite direction. Instead of cancelling our old request when a new one comes in, lets ignore later requests until our first one finishes.

Start with another request generator function.

1function controlledRequestGen(url) {
2 let allowRequests = true;
3}

This wraps two variables in our function closure: url and allowRequests. We can make a simple implementation of this to get started.

1function controlledRequestGen(url) {
2 let allowRequests = true;
3
4 return async (params) => {
5 if (allowRequests) {
6 allowRequests = false;
7 const fullUrl = url + paramterize(params);
8 const request = new Request(fullUrl);
9 const response = await fetch(request);
10 const result = await response.json();
11
12 allowRequests = true
13 return result;
14 } else {
15 return false;
16 }
17 }
18}
19
20const getFromDatabase = controlledRequestGen("http://my.api.server/db")
21
22window.addEventListener('scroll', e => {
23 if (e.scrollY > 100) {
24 getFromDatabase({ page: page + 1 }).then(appendToDom)
25 }
26})

Our function will request another page the first time that the scroll event fires. Subsequent requests are ignored until the first one has finished.

Lets assume that not only do we not want to make a request if another is happening, but also until the user has finished some other operation. Maybe not only do we have to wait for the request to finish, but maybe the code needs to finish appending our results to the DOM. We can also give our users some functionality to "allow" requests to happen again.

1function controlledRequestGen() {
2 let allowRequests = true;
3 const makeRequest = async (url, params) => {
4 if (allowRequests) {
5 allowRequests = false;
6 const fullUrl = url + paramterize(params);
7 const request = new Request(fullUrl);
8 const response = await fetch(request);
9 const result = response.json();
10
11 return result,
12 } else {
13 return false;
14 }
15 }
16
17 return {
18 requester: makeRequest
19 allowMoreRequests: () => {
20 allowRequests = true
21 }
22 }
23}
24
25const { requester: getFromDatabase, allowMoreRequests } = controlledRequestGen("http://my.api.server/db")
26
27window.addEventListener('scroll', e => {
28 if (e.scrollY > 100) {
29 const promise = getFromDatabase({ page: page + 1 })
30 .then(appendToDom)
31 .then(allowMoreRequests)
32 }
33})

When we finished appending to the DOM, we let the original requesting function its ok to accept more events and get more items from the database. For 99% of our use cases, this is all we need. There is a slight catch though. We have given our users the ability to override and allow requests even if the first hasn't finished. With great power comes great responsibility

Wrapping up

These two functions are just examples of how thinking at a higher level of what we want to do can help create a simpler, more declarative code base. The details of these functions may be more complicated than a simple fetch, but the people using them have less to worry about. Trying to wrap up bits of functionality into separate, composable pieces is one of the best ways that I have found to write great code.