Smart Software Solutions Inc 108 S Pierre St.
Pierre, SD 57501
605-222-3403
sales@smartsoftwareinc.com

Contact Us

Articles

Asynchronous Event Handling with Promises

Published 9 months ago

A Promise is effectively an envelope representing a promise to deliver an object that currently is not available. Their use is valuable in concurrent programming languages; such as JavaScript.

Synchronous (Blocking) Logic

As an engineer, you understand that your application is generally comprised of sequential actions and decisions. IE: Do this, then this, if this, then that. This is synchronous logic. Each action must start and complete in full before the next action can start.

When looking to solve a finite problem, ignoring the scope of the rest of the application, synchronous execution logic is the most likely solution.

Example

Let's take a use case where we must:

  1. Filter an array of objects
  2. Open a file reference
  3. Serialize the objects to the file
  4. Provide the user with an output summary

In this case, we see each action starts when the prior one ends; as it must, you'd think. At the very least, you can't serialize the objects to file till you know which objects will be written. More so, you can't tell the user you are done till you are actually done.

In the meantime, from cycle 1 (A) to 18 (D), the user has no idea what the system is doing or when it'll be done. Moreover, it is the ONLY thing the system is doing.

Asynchronous (Event-Driven) Logic

So you have your object exporter working and all the data is good. Unfortunately, the users complain that it's taking a very long time to get their data, during that time the system just sits there. There is nothing you can do to make this faster; data must be filtered and then written. However, you can design the system to do the work in the background and inform the user when it's done.

Example

Same use case before, except we tell the system to export a file and ask it to tell us when it's done. As you can see from the timeline, we've not changed the amount of work that needs to be done, we've simply decoupled the start and finish logic so that the user is free to do other things between B and E.

Call Me Maybe

Now here comes the interesting part. How do we get from point A to E?

This is most commonly handled with a callback. A callback is a reference to a function that should be invoked when the work is done. The logic sequence in the top line is responsible for determining what causes the export to start, and what to do when it's done. It doesn't care about the middle.

CallBack Example

From a JavaScript perspective, a function reference can be a nested anonymous enclosure. This is really simple.

From Consumer Perspective (Top Green Line)

function doExport() {
	console.log("starting");
	dataExporter.export( function(exportFile) {
		console.log ("done!");
	});
	console.log ("waiting");
}

From Provider Perspective (Bottom Line)

function export(callback) {
	// Do stuff
	callback();
}

CallBack Sequence Analyzed

If you were to watch the log output from the consumer side, you'd see logic that read:

  1. starting
  2. waiting
  3. done

Notice, it's out of sequence from the code logic. The reason for this is because the anonymous enclosure function is only defined and referenced, but not executed at the time export() is called. Export's reference to callback is an invocation of that function passed by reference.

CallBack Limitations

Callbacks, while simple to implement, do have their limitations. The most obvious is the unintuitiveness of passing a function reference to a function. You get used to it, but it makes the code difficult to read.

The more onerous is when you must chain asynchronous activities. Say for example, that after you get the data you also want to post it to an API in bulk. You could wait till the file is written to disk to post to the API, but the most efficient is to execute the work as soon as the dependent activity is complete. This means the disk and api writing are happening at the same time, but not necessarily completing at the same time. Meaning you must track and collect all your call backs before reporting done.

This problem is compounded because those aforementioned anonymous enclosures have an isolated scope; meaning you can't really pass data back to the parent scope without some sort of delegate. That is a whole other article in itself.

Multi-thread With Callbacks

But let's say we attempt that use case anyways. In the diagram below, the lines repsenent:

  • Green – Decision logic, or consuming logic
  • Magenta – Implementation logic for Posting to the API
  • Orange – Implementation logic for writing to file
  • Blue – Logic for processing data
  • Yellow – Logic for preparing data output

As we can see, as soon as the data is prepared for output, that data can be simultaneously written to file and posted to an API. Additionally, at each of the green points, we have the opportunity to interact with the logic flow and inform the user of status changes (or conduct error handling).

At point H above, asynchronous processing is complete and synchronous processing for that logic thread continues. The problem is determining when H occurs.

Joining Callbacks

Observe the example below. Here, our decision logic implementation now exposes its own callback, while leveraging the callbacks of writeToFile() and writeToAPI(). When both those are done, we call our callback.

From Consumer Perspective

function doExport(exportDoneCallback) {
	dataExporter.export( function(exportData) {
		var fileDone = apiDone = false;
		
		writeToFile(function() {
			console.log("file written");
		});
		
		writeToAPI(function() {
			console.log("API written");
		});
		
		setInterval(function() {
			if(fileDone && apiDone) {
				exportDoneCallback();
			}
		}, 1000);
	});
}

Analysis of the Callback Joining

You'll notice something taboo: setInterval. What this does is once a second it checks to see if both functions are done. Granted, this is better than using a while(true) check, which would be blocking, but it adds the marginal overhead of rechecking doneness in a digest cycle. Never mind that I didn't bother to halt the timer.

I very much dislike this mostly because of the code organization. It's very difficult to read. Especially if you have extensive logic in your callback handlers. In my opinion, those handlers should only have 1-2 lines of logic. If it's more than that, then you have too much implementation logic in your decision logic.

Some may argue  you could declare your handlers elsewhere and reference them. I dislike this even more as it means continually cross-referencing sections of code. This is how you end up with 1000+ line class files. Even more taboo.

Event Driven Asynchronous Logic

I am not going to delve into this in too much detail, but will make mention of it. Instead of callbacks, you can broadcast and listen to events.

The Pros are that it's easy to implement and permits multiple consumers to listen to the event. Take our prior example, implemented with events.

function doExport() {
	dataExporter.on(dataExporter.EXPORT_DONE, fileWriter.write);
	dataExporter.on(dataExporter.EXPORT_DONE, apiWriter.write);
	fileWriter.on(fileWriter.WRITE_DONE, function() {
		this.dispatch(this.EXPORT_FILE_DONE);
	});
	apiWriter.on(apiWriter.WRITE_DONE, function() {
		this.dispatch(this.API_FILE_DONE);
	});
}

Analysis of the Event Logic

At first glance, this looks way cleaner than callbacks. And it is. It's also extremely scalable for firing many asynchronous events once a dependency is revolved.

However, the downside is that you must be extremely conscious of your broadcast and listening scope. Now that anyone can listen to an event you broadcast, you'll have an extremely difficult time tracking logic defects.

Enter the Promise

A promise is simply an envelope object that can be returned immediately so that synchronous logic may continue while asynchronous logic completes. When that logic is complete, the promise is resolved (or rejected).

In reality, it's just a more complex way of doing callbacks, but in a more uniform and predictable way.

Consuming a promise

Let's reuse our first example above of writing our data to disk and informing the user when done. The visual timeline is the same.

Decision Logic

function doExport() {
	return dataExporter.export()
		.then(function(data) {
			console.log("file written");
		});
}

Implementation Logic

function export() {
	var promise = new Promise();
	
	writeFile(function() {
		promise.resolve(data);
	});
	
	return promise;
}

Analysis of Promise Logic

Notice here that instead of passing a function reference into export(), we call then() on the result of export(). We can do this because we know that export is returning a promise object. Instead, our callback is passed into the then() function.

It's important to understand blocking vs non-blocking logic and execution time of enclosures. In the above implementation, export may or may not implement some blocking logic, even though it shouldn't. It should immediately return its promise.

Keep in mind that although export may return its promise, the promise may be resolved in a child asynchronous return. Since that scope still holds a reference to the promise, it can (and will) adjust the result of the promise after its parent scope has returned the promise.

You can now see export() is asynchronous by the uniform use of the then(). It also makes anonymous enclosures easier to format without chasing the hanging parenthesis from export.

The real beauty of the promise design pattern is in the `return` that we have before export(). The then function will always return an encapsulated promise. This means whoever called our doExport can expect a promise and then chain off it.

Chaining Promises

Let's say that after the file is written, we want to synchronously copy that file to backup.

We'd then have both an export and backup routine that are known to return promises. In the example below, after export() resolves its promise, the function reference passed to then is invoked. The response from the first then will always be a promise, but if the internal function is also returning a promise, you can simply reference without an enclosure.

Decision Logic

function doExport() {
	return dataExporter.export()
		.then(
			dataExporter.backup
		)
		.then(function(data) {
			console.log("backup written");
		});
}

Implementation Logic

// Assume export logic is the same as above
function backup() {
	var promise = new Promise();
	copyBackup(function() {
		promise.resolve(data);
	});
	return promise;
}

Analysis of Promise Chain

You can see that our decision logic is now extremely easy to read and trace both the timing and sequence of events.

Do take careful note of my whitespacing and the absence of a semicolon after the first then(). This is deliberate. Since then() is returning a promise, you could assign that object to a variable, and then call your next then on it. However, doing so adds 3 lines and another 40-some characters to your code.

Doing the same work with less code is almost always favorable as it improves readability and ultimately maintainability of your code.

Joining Promises

Remember our example of the multi-threaded asynchronous processing that involved writing to file and an API? Let us resume it, but this time with promises.

Most promise frameworks expose an all() method that takes in an array of promise objects or functions resolving promises. When all of its promises are resolved, then it resolves its promise.

Decision Logic

function doExport() {
	return dataExporter.export()
		.then(function(data) {
			return Promise.all([
				fileWriter.write(data),
				apiWriter.write(data)
			]);
		})
		.then(function() {
			console.log("all done");
		});
}

Analysis of Promise Join

We can see from above that we are going to:

  1. Export the data, getting back a data object in the resolved promise
  2. Simultaneously invoke the file and api writers, passing our data, and expecting them to return a promise.
  3. Allow Promise.all to track all dependent promises, and then return a promise
  4. Announce we are all done

Rejecting a Promise

In the Promise design pattern, a promise object usually exposes catch() and finally() routines. When a promise is resolved, it may optionally be rejected or an error may be thrown. The callback function passed to catch will handle rejected or thrown promises. The finally will get invoked regardless of whether the promise is rejected or resolved. Let's take another look at the decision logic for our first promise example. We must acknowledge that export() might fail. When it does, it'll reject our promise.

Decision Logic

function doExport() {
	return dataExporter.export()
		.then(function(data) {
			console.log("file written");
		})
		.catch(function(error) {
			console.log("export failed!");
		})
		.finally(function() {
			console.log("all done");
		});
}

Analysis of Catch and Finally

Catch and finally, like then, also return promises; which are subsequently resolved or rejected accordingly.

So in our code example above, say that export() resolved its promise. Because it's resolved, the promise object there would invoke then's callback, but not catch's callback. Catch still returns a promise, but it's cascaded so it's resolved.

But, however, if export rejects its promise, then it calls catch's callback but not then's. Again, catch return's its promise, but it is rejected because of the cascade.

What this means is that you can use any combination of then() and catch() to handle rejected and resolved promises. Examples of controlling when to halt or continue the chain based on rejected promises is a topic for another day.

Finally() will still return a promise, resolved or rejected based on chain cascade so that whoever invokes doExport can do their own promise resolution.

Downside of Promises

Despite promises flexibility and easy of use, they still have their caveats. The biggest one is being able to understand the execution chain. It's very easy to forget to return a promise and then expect one further upstream. Worse yet, Promises are typically used in loosely-typed languages, like JavaScript. Because of this, there is not a good way to know if a function will or will not return a promise. To add insult to injury, there is no guarantee that the promise framework they are using is the exact same as yours.

This is where documentation comes into play. If you are exposing a promise, you are expected to properly document that and reference your promise framework.

Continue Your Education

The promise design pattern has grown in popularity to the point that most modern libraries utilize promises. Angular and React are prime examples. For those that don't, most all languages have a standard library in the community.

For JavaScript, I'd like to draw your attention to https://www.promisejs.org/. It's easily resolved with bower or npm.

AUTHOR Jesse Bethke

With over 10 years of web application development experience, Jesse holds a Bachelor's of Business Administration in Management and a Bachelor's of Art in Computer Science from the University of South Dakota.

Jesse now manages the newly opened Las Vegas office and personally oversees the recruiting and training there.