← Back to Blog
Concurrency Two code blocks illustrating the Zalgo anti-pattern: a releaseZalgo function that invokes its callback either synchronously or asynchronously depending on a condition, and the calling code for which the execution order of 'Before', 'Done' and 'After' becomes impossible to determine.

Callbacks: the Zalgo anti-pattern, never mix sync and async

Why a callback that is both synchronous and asynchronous is an anti-pattern (Zalgo): non-deterministic control flow, errors, testing, and the Effect solution.

📅 ✍️ Antoine Coulon
callbacksasynczalgocontrol-floweffect

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:

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:

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.