If there is one single thing to remember about designing callback-based APIs, it’s this: a callback must never be able to be both synchronous and asynchronous. That seemingly harmless ambiguity tips a deterministic program into total uncertainty, so much so that it has a name that became famous in the JavaScript community: Zalgo.
This second installment of the callbacks series picks up where the previous one left off. In the first episode, we saw that a callback isn’t necessarily asynchronous: depending on the API, it can be invoked immediately or later. The natural question that follows is this: what if a single function could do both, sometimes synchronous, sometimes asynchronous? That’s exactly the trap we need to defuse.
Mixing sync and async: a major anti-pattern
Why is it so harmful to let a callback swing between the two modes? Because synchronous and asynchronous don’t follow the same rules. Mixing them makes four fundamental aspects of a program inconsistent:
- Execution nature, which becomes non-deterministic: there’s no way to know in advance when the callback will be called.
- Error handling, which differs depending on whether an exception is thrown in the same call stack or later, in another tick of the event loop.
- Testing, which can no longer rely on stable, reproducible behavior.
- Control flow, that is, the order in which instructions actually run.
It was Isaac Z. Schlueter, the creator of npm, who gave this anti-pattern a name in his article Designing APIs for Asynchrony (2013). He named it Zalgo, after a malevolent and mysterious entity from internet folklore, a metaphor for the unpredictable chaos you summon without meaning to.
Anatomy of Zalgo
Consider a function that, depending on an arbitrary condition, invokes its callback immediately or after a delay:
// Arbitrary boolean condition
let someCondition;
/**
* Here, the callback may either be invoked immediately
* in a synchronous way, or asynchronously depending on
* the `someCondition` boolean condition.
*
* This is where Zalgo comes into play. A mix between
* the two types of callbacks, which we want to avoid at
* all costs.
*/
function releaseZalgo(callback) {
if (someCondition) {
callback("Done");
} else {
setTimeout(() => callback("Done"), 1_000);
}
}
Depending on someCondition, the callback is either called right away, in the same execution stack, or deferred by at least one second via setTimeout. From the caller’s point of view, then, nothing can be guaranteed about when the callback will run.
Now let’s look at the consuming code:
console.log("Before");
releaseZalgo(() => {
console.log("Done");
});
console.log("After");
What is the order of the messages in the console? The honest answer is: it depends. Two scenarios are possible depending on which branch is taken.
If the callback is synchronous:
Before
Done
After
If it’s asynchronous:
Before
After
Done
A single call, two radically different behaviors. It becomes impossible to reason deterministically about the program’s execution order, because of the uncertainty over the nature, synchronous or asynchronous, of the callback. Every bit of logic you build on top then rests on sand.
The golden rule
The fix is simple and fits in one sentence:
If a callback can be asynchronous, then it must always be asynchronous.
In other words, you remove the ambiguity by choosing a single, stable mode. As soon as a single branch of your function is asynchronous, force all branches to be so. Concretely, in the synchronous case, you artificially defer the call so that it always happens in a later tick of the event loop:
function releaseZalgo(callback) {
if (someCondition) {
// Force asynchronicity even in the "synchronous" branch
queueMicrotask(() => callback("Done"));
} else {
setTimeout(() => callback("Done"), 1_000);
}
}
With this fix, the order is guaranteed: Before, After, then Done, whatever the condition. The consumer can finally reason calmly about its control flow. We’ve tamed Zalgo by imposing some discipline on it: an API must have predictable and uniform temporal behavior.
”Callbacks are so 2013”
One could object that this debate belongs to the past. It’s true that callbacks are no longer the dominant way of handling asynchrony: Promises, then async/await and generators have largely taken over. But two reasons make this principle just as relevant today.
On one hand, a large part of the JavaScript ecosystem remains callback-first: countless legacy APIs and modules still expose callbacks, and you have to deal with them. On the other hand, and this is the key point, the rules don’t change. A Promise that resolves unpredictably, a stream that mixes synchronous and asynchronous emissions: Zalgo reincarnates in other forms. The principle of predictability transcends the mechanism in use.
The future: when sync and async become implementation details
If we step back, the real problem isn’t the callback itself, but the fact that the nature, synchronous or asynchronous, of an operation leaks all the way into how we write it and reason about it. What we’d ideally want is to think in terms of composing operations, without having to worry about their underlying nature. We’d want to be able to:
- handle concurrency in a transparent and explicit way;
- handle errors uniformly, regardless of the execution mode;
- preserve exactly the same control flow, the same understanding of the program;
- rely on a unified, unambiguous API and behavior.
This is exactly what Effect offers. This library adopts a vision in which you write synchronous and asynchronous code the same way in TypeScript: the distinction becomes an implementation detail, no longer a source of uncertainty that contaminates the rest of the program. Determinism is no longer a discipline you impose by hand: it’s guaranteed by construction.
Conclusion
Zalgo isn’t a folkloric curiosity: it’s a reminder that an API is judged as much on the predictability of its behavior as on what it accomplishes. A callback that wavers between synchronous and asynchronous destroys the one thing a consumer can rely on: the certainty of the execution order.
The rule is non-negotiable: if a callback can be asynchronous, it must always be. And beyond callbacks, the stakes stay the same whatever the tool (Promises, generators, or Effect): to enable deterministic reasoning about the program. It’s that requirement, far more than the technology of the moment, that separates a reliable API from a silent trap.