Intentionally unleashing Zalgo with synchronous promises

Daniel Brain
7 min readJun 15, 2017

--

This article is part of a series on PayPal’s Cross-Domain Javascript Suite.

Before you start reading, I’d recommend taking a look at this article: http://blog.izs.me/post/59142742143/designing-apis-for-asynchrony

This is where Isaac Z. Schlueter first explained his term “releasing Zalgo”, with respect to asynchronous javascript paradigms like callbacks and promises.

What is Zalgo?

Releasing Zalgo is, essentially, building a function which could be resolved asynchronously or synchronously, without the caller having any clear signal about which happened. For example:

function getUsers(callback) {
if (cache.users) {
callback(null, cache.users); // synchronous callback
} else {
ajax('/api/users', callback); // asynchronous callback
}
}

This is generally considered a bad thing, because:

  1. It can result in unexpected race-conditions.
  2. It changes which call-stack exceptions are thrown into.
  3. It generally makes the function less easy to reason about.

Promises

Promises mitigate most of these concerns, since, by definition, they will only resolve asynchronously, meaning it’s impossible to read a value from a promise synchronously.

Schlueter argues:

Personally, I don’t actually care all that much about the whole promises/callbacks/coroutines/generators debate. It’s provably trivial to turn any callback-taking API into a promise-returning API or generator-yielding API or vice-versa.

I think there is a difference, though, especially between callbacks and promises, when it comes to avoiding Zalgo.

I’m going to argue that what may be true for callbacks, is not necessarily true for promises, and a lot of the arguments above aren’t actually all that compelling when you start trying to apply them to promises.

Let’s pretend for the sake of this article that we have a synchronous version of Promise. We’ll call it ZalgoPromise. Like vanilla callbacks, we’ll say a ZalgoPromise can be resolved either synchronously, or asynchronously, at the whim of the person using them. Sound like a terrible idea? Let’s find out.

1 — Race conditions

First let’s take that getUsers function we defined above. Let’s say we call it, and rely on it being totally synchronous:

let state = {};getUsers((err, result) => {
if (err) {
console.error(err);
} else {
state.users = result;
}
});
document.querySelector('#hello').addEventListener('click', () => {
console.log('Hello', state.users[0].name);
});

Our program will chug along happily, since we know state.users will be available way before that click handler is ever called.

That is, until we hit a case where the cache isn’t primed with users, and our callback is called asynchronously. Then, all of a sudden, we have a potential null-pointer when we try to access users[0] in that event handler, since state.users may not have been populated yet.

The problem with this example is, aside from being pretty contrived, it’s bad code no matter whether or not Zalgo is invoked and no matter whether getUsers() is synchronous or asynchronous. If any code relies on data from a callback being available outside of the callback, and the code doesn’t have a reliable hook to await that data being available, it has bigger problems than Zalgo. Even in a Zalgo-free world, it’s going to be plagued by race-conditions.

This problem is mostly solved by using promises, which can easily be cached and awaited on whenever their values are needed, resulting in a lot more reliability for accessing asynchronous state:

let state = {};state.usersPromise = getUsers();function sayHelloToFirstUser() {
state.usersPromise.then(users => {
console.log('Hello', users[0].name);
});
}

Notice something — even if the promise in this example is a ZalgoPromise, and we don’t know whether or not it’s asynchronous, the code still works exactly as we would expect it to. The important thing isn’t that we knew getUsers was asynchronous or synchronous, it’s that we awaited the data at the point we needed to use it.

2 — Exceptions and call stacks

Exceptions are lethal when it comes to writing code with callbacks.

Let’s demonstrate with getUsers() again.

function getFirstUserName(callback) {
return getUsers((err, users) => {
if (err) {
callback(err);
} else {
callback(null, users[0].name);
}
});
}

We’ve introduced a bug here: what if users is an empty array? We’re going to get a null pointer when we try to access users[0].name. Such is life, we should expect the odd bug to creep into our code. What’s important, at the very least, is that we can handle the error.

For starts, we know the error is not going to be automatically passed to our callback. So let’s attempt a try/catch:

try {
getFirstUserName(callback(err, name) {
if (err) {
console.error(err);
} else {
console.log('Hello', name);
}
})
} catch (err) {
showErrorPage();
}

Notice the problem here? If getUsers() calls the callback synchronously, the null pointer will get caught in this try/catch. But if getUsers() calls the callback asynchronously, the error will be lost in the ether, and will be essentially un-handleable. Our app will have no way to know whether the function succeeded or failed.

But again: this is not a problem caused by Zalgo. It’s a fundamental problem with the callback model. Even if getUsers() was consistently asynchronous, we would still have no way to handle that null pointer, we would get no callback, and our app would hang.

Promises to the rescue!

function getFirstUserName() {
return getUsers().then(users => {
return users[0].name;
});
}

Now, if there’s a null pointer in our .then() function, the error will be automatically caught by the promise’s .then() handler, and the promise will be rejected, allowing me to .catch() the exception gracefully.

Again — notice that this benefit of promises is true regardless of whether our promise is resolved synchronously or asynchronously. So a ZalgoPromise provides the exact same error handling benefit as a regular 100% asynchronous promise.

3 — Difficulty reasoning about Zalgo

I won’t contest that a function is implicitly harder to reason about if you don’t know whether the result is going to be asynchronous or synchronous.

The thing is, that difficulty becomes a whole lot less of an issue if you follow one simple rule:

Never rely on the asynchronicity or timing of a promise

  • If you need to access the result of a promise, call .then()
  • If you need to handle an error from a promise, call .catch()
  • If you didn’t await the result of the promise, don’t ever rely on the result being available, or the action being complete

Turns out if you follow these rules, it becomes very easy to reason about promises whether they’re asynchronous or not. It doesn’t matter whether the promise resolved immediately or at some indeterminate point in the future — since either way, you’re accessing the result of the promise the exact same way.

The important factor should always be how you got the asynchronous (or synchronous) result, not when — and Promises (including ZalgoPromises) give a normalized way to access the result, no matter whether it’s available now, or in the future.

Isn’t this all a moot point, since Promises are async anyway?

Promises are asynchronous, always, so why even talk about whether or not Zalgo is an issue? If we don’t care when a promise is resolved, why even discuss the possibility of having a ZalgoPromise? Where does it get us?

Turns out there is actually a sizable use-case we came across at PayPal, while we were building zoid as a toolkit for cross-domain components. It roughly went like this:

  • Everything to do with cross-window or cross-domain communication is by its very nature asynchronous, particularly the postMessage api for messaging between different windows and frames.
  • We wanted to use promises to deal with all of this asynchronicity, given the benefits of safer error handling, reduced boilerplate, and cacheability.
  • Native promises are not something that are going to be supported in all of PayPal’s supported browsers for a long time. As such, we needed to use a promise polyfill.
  • Promise polyfills, in order to be compliant with the promise spec, need to be always-asynchronous, meaning the handler passed to .then() can never be called synchronously.
  • As such, any promise polyfill needs to force asynchronicity if a user tries to resolve it synchronously. The only way to do that consistently across all browsers is to setTimeout on the handler passed to .then().
  • The problem is, setTimeout gets massively de-prioritized by many browsers when the tab isn’t focused… so if you’re in a popup window, and you need to message the parent window to do something in the background, calling setTimeout means you’re not likely to get a response any time soon.
  • This kills the idea of cross-domain components which can seamlessly pass props and call callbacks between multiple frames and windows.

So— we wanted promises, we wanted a promise polyfill, but we didn’t want any setTimeout creeping into our code. So, we wrote a promise implementation without it.

Remember ZalgoPromise from earlier? Yeah, we actually built it and open-sourced it. We’ve been running it on tens of thousands of sites for a year now, and the number of Zalgo related bugs there have been is exactly zero. That’s not to say it’s impossible — if you’re writing race-condition prone code, Zalgo can bite, but in our view it’s a lot more friendly a beast than people make it out to be.

We also tried using other ways to force asynchronicity, other than setTimeout, like using window.postMessage — but this threw up some subtle bugs in old versions of IE that we didn’t want to contend with (especially since we’re pretty confident that synchronous promises aren’t necessarily all that evil).

Feel free to use ZalgoPromise, if you’re dealing with a lot of background code in unfocused windows or tabs. You can run ajax calls, post-message calls, whatever you want… just don’t run any setTimeouts!

NB: If you can point me towards a really good example of a Zalgo-caused bug, I’d love to hear about it. I’m very happy to be proven wrong.

--

--

Daniel Brain
Daniel Brain

Responses (1)