Skip to main content

The Zalgo Effect and Resource Leakage

· 5 min read
Lucas Weis Polesello
donquixote engineering

In JavaScript, Zalgo usually refers to APIs that behave synchronously in some paths and asynchronously in others.

In practice, you see the same kind of bug when sync-looking control flow is mixed with async initialization and timing becomes part of correctness.

You may not notice it in a toy example, but you absolutely notice it in production.

This post is about one specific variant: lazy initialization with a concurrency bug, and the single change that fixes it.

The incident​

One day our site reliability engineering (SRE) team started receiving PagerDuty alerts from services that were repeatedly restarting with errors like:

Error: No channels left to allocate

The affected services used RabbitMQ, a message broker. Its lightweight communication streams, called channels, were being exhausted, which meant something was creating channels and not reusing them properly.

The failing service mostly fanned out messages to many other services. At first glance, that should have been simple: create a queue abstraction, publish concurrently, move on.

But RabbitMQ management showed that the connection had accumulated far more channels than expected.

Something was leaking.

Why it only happened sometimes​

That was the clue.

If a resource leak appears only under certain timing conditions, the bug often lives in initialization or concurrency rather than in a simple missing cleanup call.

Our messaging abstraction dated back to the Node 4 era, when callback-style code was still the default. It had grown around that model for years.

The queue abstraction had two channel concepts:

  1. a consume channel, created during connection setup
  2. a confirm channel, created lazily when publishing

The problem lived in the second one.

The bug pattern​

The publish path roughly did this:

  1. check whether confirmChannel already exists
  2. if it does, return it
  3. if it does not, create it asynchronously and assign it later

That seems harmless until two concurrent publish operations hit the same check before the first initialization finishes.

In that window:

  • request A sees no channel and starts creating one
  • request B also sees no channel and starts creating one
  • both channels are created
  • the last assignment wins
  • the earlier channel stays open but is no longer referenced

That is the leak.

Not because RabbitMQ was strange, but because our lazy initialization had a race condition: its result depended on which overlapping request ran first.

How to spot this in your own code​

Look for patterns like this:

if (!this.resource) {
this.resource = await createResource();
}

If that check runs concurrently, you have the same race condition. The fix is to cache the promise, not the result.

Why this is Zalgo-shaped​

The core problem was that resource existence was checked synchronously while resource creation finished asynchronously.

That made the correctness of the code depend on timing.

Whenever timing becomes part of the contract by accident, Zalgo is usually nearby.

The fix​

The first fix we shipped was conservative: initialize the channel once, await it, and only then fan out the publish operations.

That was the lowest-risk change.

The more robust long-term fix is a standard pattern:

  • store the initialization promise itself
  • return the same promise to all concurrent callers
  • replace or clear it if initialization fails and must be retried

In other words, do this:

class Queue {
constructor(connection) {
this.connection = connection;
this.confirmChannelPromise = null;
}

getConfirmChannel() {
if (!this.confirmChannelPromise) {
this.confirmChannelPromise = this.connection.createConfirmChannel();
}

return this.confirmChannelPromise;
}
}

Now the first caller starts creation, and every concurrent caller awaits the same promise.

No duplicate channels. No orphaned resources. No timing-dependent correctness.

Why this works​

The important detail is that assigning the promise is synchronous.

The async work still happens later, but the decision that initialization is already in progress happens immediately in the same synchronous execution block.

That gives you one shared future instead of several competing initializations.

The core lesson​

When lazily initializing resources that finish setup later in Node.js, do not cache only the resolved resource.

Cache the promise.

That one detail is the difference between:

  • one initialization
  • and several expensive, leaking, racing initializations

When this pattern does not apply​

Do not use promise caching when:

  • the resource must be recreated after every failure
  • initialization is intentionally retried with different parameters
  • you need to force re-initialization on every call for testing

In those cases, use explicit initialization or a factory instead.

Final thought​

A lot of old NodeJS systems carry abstractions built in a callback-first world and then gradually adapted to promises.

That is exactly where bugs like this tend to hide.

If a resource is created lazily and accessed concurrently, inspect the initialization path carefully. If the code checks synchronously and assigns asynchronously, you may already have a leak waiting for traffic.

Quick code-review checklist​

  • Lazy initialization happens in a concurrent path
  • The existence check is synchronous but the creation is async
  • The result is cached but the promise is not
  • There is no retry or failure cleanup logic
Share: