DEV Community

Cover image for Building a Real Event Bus in VibeCodeArena
YASHWANTH REDDY K
YASHWANTH REDDY K

Posted on

Building a Real Event Bus in VibeCodeArena

There’s a moment in every growing codebase where things start to feel… tangled.

A button click updates three different parts of the UI.
A network response triggers state changes across multiple modules.
A login event suddenly needs to notify analytics, UI, cache, and permissions.

At first, you wire things directly:

loginButton.onclick = () => {
  updateUI();
  trackAnalytics();
  refreshData();
};
Enter fullscreen mode Exit fullscreen mode

It works.

Until it doesn’t.

Because now everything knows about everything.

And that’s where an event bus quietly becomes one of the most powerful abstractions in your system.

The Simple Idea That Hides a Complex System

At its core, an event bus sounds trivial:

bus.on("user.login", handler);
bus.emit("user.login", data);
Enter fullscreen mode Exit fullscreen mode

You publish an event.

Subscribers react.

Loose coupling. Clean architecture. Done.

Except… that’s just the surface.

The moment you try to build one that feels production-ready, the complexity starts creeping in from every direction.

The Naive Version (and Why It Breaks)

Most implementations begin like this:

const events = {};

function on(name, handler) {
  if (!events[name]) events[name] = [];
  events[name].push(handler);
}

function emit(name, data) {
  (events[name] || []).forEach(fn => fn(data));
}
Enter fullscreen mode Exit fullscreen mode

This works.

Until you need:

  • To remove a handler
  • To handle async logic
  • To prevent memory leaks
  • To debug what’s happening

And suddenly, this “simple pub-sub” becomes a system design problem.

The First Real Upgrade: Control Over Subscriptions

You quickly realize on isn’t enough.

You need off.

function off(name, handler) {
  if (!events[name]) return;
  events[name] = events[name].filter(h => h !== handler);
}
Enter fullscreen mode Exit fullscreen mode

And then:

bus.once("user.login", handler);
Enter fullscreen mode Exit fullscreen mode

Which means wrapping the handler:

function once(name, handler) {
  const wrapper = (data) => {
    handler(data);
    off(name, wrapper);
  };
  on(name, wrapper);
}
Enter fullscreen mode Exit fullscreen mode

This is the first moment where your event bus starts managing lifecycle, not just execution.

Wildcards: When Events Become Patterns

Now imagine:

bus.on("user.*", handler);
Enter fullscreen mode Exit fullscreen mode

You emit:

bus.emit("user.login");
bus.emit("user.logout");
Enter fullscreen mode Exit fullscreen mode

Both should trigger the same listener.

Now your lookup is no longer direct.

You’re matching patterns.

function matches(pattern, event) {
  if (pattern.endsWith("*")) {
    return event.startsWith(pattern.slice(0, -1));
  }
  return pattern === event;
}
Enter fullscreen mode Exit fullscreen mode

This is where your event system starts behaving more like a router than a map.

Namespaces: Structure Matters

Then you introduce:

auth:login
auth:logout
Enter fullscreen mode Exit fullscreen mode

Now events are not just strings.

They’re hierarchies.

And suddenly, design decisions matter:

  • Should auth:* catch both?
  • Should * catch everything?
  • Should auth:login.success match auth:*?

You’re no longer just dispatching events.

You’re defining a language of communication inside your app.

Priority: Who Gets to Speak First?

When multiple handlers exist:

bus.on("data.update", handlerA);
bus.on("data.update", handlerB);
Enter fullscreen mode Exit fullscreen mode

Order suddenly matters.

So you introduce priority:

bus.on("data.update", handlerA, { priority: 10 });
bus.on("data.update", handlerB, { priority: 1 });
Enter fullscreen mode Exit fullscreen mode

And sort:

handlers.sort((a, b) => b.priority - a.priority);
Enter fullscreen mode Exit fullscreen mode

This is subtle, but powerful.

Because now your system supports controlled execution flow without hard dependencies.

Async Handlers: Where Things Get Real

Now consider:

bus.on("user.login", async (data) => {
  await fetchUserProfile();
});
Enter fullscreen mode Exit fullscreen mode

What happens when you emit?

await bus.emit("user.login", data);
Enter fullscreen mode Exit fullscreen mode

Now your emit must:

  • Detect async handlers
  • Await them properly
  • Handle errors
async function emit(name, data) {
  for (const handler of handlers) {
    try {
      await handler(data);
    } catch (err) {
      console.error(err);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This is where your event bus stops being synchronous glue…

…and becomes part of your async control flow.

Middleware: The Hidden Power Layer

Now introduce middleware:

bus.use((event, data, next) => {
  console.log("Event:", event);
  next();
});
Enter fullscreen mode Exit fullscreen mode

Before any handler runs, middleware intercepts.

This enables:

  • Logging
  • Validation
  • Transformation
  • Security checks

Your emit pipeline becomes:

middleware  handlers
Enter fullscreen mode Exit fullscreen mode

And now your event system feels like a mini framework.

Debug Mode: Making the Invisible Visible

Without visibility, debugging events is painful.

So you add:

debug: true
Enter fullscreen mode Exit fullscreen mode

And log:

[EMIT] user.login
 handlerA (2ms)
 handlerB (5ms)
Enter fullscreen mode Exit fullscreen mode

Now you can see:

  • Execution order
  • Performance
  • Missing handlers

This transforms your bus from a black box into an observable system.

The Edge Case Nobody Talks About: Unsubscribing During Emit

Consider:

bus.on("test", function handler() {
  bus.off("test", handler);
});
Enter fullscreen mode Exit fullscreen mode

If you mutate the handler list during iteration, you risk:

  • Skipping handlers
  • Crashing iteration

So you clone:

const handlersCopy = [...handlers];
handlersCopy.forEach(h => h());
Enter fullscreen mode Exit fullscreen mode

This is the kind of detail that separates a working system from a reliable one.

Re-entrancy: Events Triggering Events

Now imagine:

bus.on("A", () => bus.emit("B"));
bus.on("B", () => bus.emit("A"));
Enter fullscreen mode Exit fullscreen mode

You’ve just created an infinite loop.

Handling this requires:

  • Guarding against recursion
  • Tracking active events

This is where your event bus starts dealing with system safety, not just features.

Memory Leaks: The Silent Failure

Every on() adds a reference.

If you don’t clean up:

  • Detached components still receive events
  • Memory grows silently

A robust bus ensures:

  • Proper off() usage
  • Weak references (if possible)
  • Cleanup patterns

Because leaks don’t show immediately.

They show in production.

The UI: Where It All Comes Together

Once you visualize:

  • Active listeners
  • Event logs
  • Execution order
  • Performance timing

The system becomes tangible.

You can:

  • Emit custom events
  • Watch handlers fire
  • Measure execution time

And suddenly, this isn’t just infrastructure.

It’s something you can interact with.

Replay: Time Travel for Events

One of the most interesting features is replay:

bus.replayLast(10);
Enter fullscreen mode Exit fullscreen mode

Now your system remembers history.

This enables:

  • Debugging
  • State reconstruction
  • Event sourcing patterns

You’ve moved from real-time system…

to time-aware system.

What You Actually Built

By the end of this challenge, you didn’t just build a pub-sub utility.

You built:

  • A pattern-matching event router
  • A prioritized execution engine
  • An async-safe dispatcher
  • A middleware pipeline
  • A debugging and observability layer

That’s not a helper function.

That’s infrastructure.

The Shift in Thinking

After building this, you stop wiring components directly.

You start thinking:

  • “What event should this emit?”
  • “Who should listen?”
  • “What’s the contract?”

You design flows, not connections.

And your system becomes:

  • More modular
  • More scalable
  • Easier to evolve

Final Thought

Most developers use event systems without thinking about them.

But the moment you build one yourself, you realize:

The real challenge isn’t sending messages.

It’s managing how, when, and in what order they flow through your system.

And once you understand that,

you don’t just write features anymore

you design communication architectures.

👉 Try it out here: https://vibecodearena.ai/share/1be49732-f7cb-49b3-976f-b9f56b8a0f03

Top comments (0)