# Common Pitfalls When Using Cordis's Event System: A Developer's Guide to Avoiding Bugs

> Learn common pitfalls when using Cordis's event system. Avoid listener leaks and silent failures by properly managing dispose functions and event references. A developer's guide.

- Repository: [Cordiverse/cordis](https://github.com/cordiverse/cordis)
- Tags: best-practices
- Published: 2026-09-12

---

**Always store and invoke the dispose function returned by `Context.on()` to prevent listener leaks, and ensure Symbol events use the exact same reference when listening and emitting to avoid silent failures.**

Cordis is a powerful plugin framework that relies heavily on its event system for inter-plugin communication and lifecycle management. When using Cordis's event system, developers often encounter common pitfalls related to listener disposal, context filtering, and event name resolution that can cause subtle production bugs. Understanding the implementation details in [`packages/core/src/events.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/events.ts) according to the cordiverse/cordis source code is essential to avoid memory leaks, execution order issues, and silent failures.

## Forgetting to Dispose Event Listeners (Memory Leaks)

The most frequent source of bugs in Cordis plugins is failing to handle the disposal of event listeners. In [`packages/core/src/events.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/events.ts), the `on()` method returns a dispose function (lines 43‑52) that removes the listener from the internal `_hooks` array. If you do not store and later invoke this function—especially during hot‑module reloads—listeners accumulate and duplicate callbacks fire.

**Correct pattern:**

```typescript
// Store the dispose function
const dispose = ctx.on('custom-event', (payload) => {
  console.log('received:', payload);
});

// Later, cleanup to prevent leaks
dispose();

```

**Common mistake:**

```typescript
// ❌ Missing disposal causes duplicate listeners on reload
ctx.on('custom-event', (payload) => console.log(payload));

```

## Misunderstanding Symbol vs String Event Names

Cordis accepts both strings and Symbols as event identifiers, but Symbol events bypass prototype‑property checks and require reference equality. As shown in the test suite [`packages/core/tests/events.spec.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/tests/events.spec.ts), using a Symbol literal inline instead of a shared constant creates distinct identifiers that never match, leading to silent failures where listeners never trigger.

**Correct usage:**

```typescript
// Define once and reuse the same Symbol reference
const SYNC_EVENT = Symbol('sync');
ctx.on(SYNC_EVENT, () => console.log('sync triggered'));
ctx.emit(SYNC_EVENT); // ✅ Works

```

**Silent failure:**

```typescript
ctx.on(Symbol('sync'), () => console.log('never fires'));
ctx.emit(Symbol('sync')); // ❌ Different references, listener never triggered

```

## Incorrect thisArg Usage and Context Filtering

The event system extracts a `thisArg` from the first argument of dispatch methods (lines 73‑75 in [`packages/core/src/events.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/events.ts)). When you pass a Context instance as the first argument to `emit`, Cordis applies filtering via `Context.filter`, causing only listeners registered on that context (or its descendants) to execute. Misunderstanding this positional argument leads to listeners being unexpectedly skipped.

**Behavior examples:**

```typescript
// Register on root and specific context
ctx.root.on('filtered', (msg) => console.log('root:', msg));
ctx.on('filtered', (msg) => console.log('scoped:', msg));

ctx.emit('filtered', 'hello');        // Both fire
ctx.emit(ctx, 'filtered', 'hello');   // Only scoped listener fires (filter applied)

```

## Execution Order and Prepend Behavior

Listeners are stored in insertion order within the `_hooks` array. By default, new listeners append to the end, but the `{ prepend: true }` option (lines 35‑38) inserts them at the front. Relying on default ordering when your logic depends on execution sequence causes race conditions, particularly when chaining configuration hooks via `internal/update`.

**Order demonstration:**

```typescript
ctx.on('order-event', () => console.log('first registered'));
ctx.on('order-event', () => console.log('second registered'), { prepend: true });
// Output: "second registered" → "first registered"

```

## Global Listeners and Cross-Plugin Side Effects

By default, listeners are scoped to the current context hierarchy. Setting `{ global: true }` (lines 35‑38) registers the listener at the root level, causing it to fire for every context. This can unintentionally create cross‑plugin side effects if the listener mutates shared state without proper guards.

**Example:**

```typescript
// Fires for any context emitting 'global-event'
ctx.on('global-event', (data) => modifySharedState(data), { global: true });

```

## Special Dispatch Modes and Error Handling

Cordis provides specialized dispatch methods that enforce specific contracts. Violating these contracts results in thrown errors or aborted execution chains.

### Waterfall next() Contract

In `waterfall` dispatches, each listener receives a `next` function and must invoke it exactly once to continue the chain (lines 25‑27). Calling `next()` multiple times throws an error, while omitting the call aborts subsequent listeners silently.

```typescript
ctx.waterfall('config', (config, next) => {
  config.value = 42;
  next(); // Required to continue the chain
});

```

### Parallel Error Aggregation

The `parallel` method executes listeners concurrently and aggregates rejections into an `AggregateError` (lines 92‑94). Ignoring this error causes unhandled promise rejections or swallowed failures.

```typescript
ctx.on('error-event', async () => { throw new Error('boom'); });

try {
  await ctx.parallel('error-event');
} catch (e) {
  console.error(e); // AggregateError containing all rejection reasons
}

```

### Once Disposal Nuances

`Context.once()` wraps `on()` and automatically disposes after the first invocation (lines 69‑74). However, if you destructure or lose the returned dispose function before the event fires, you lose the ability to manually clean up the pending listener during plugin teardown.

```typescript
// Automatic disposal after first emit
ctx.once('init', () => console.log('initialized'));

```

## Interference with Internal Events

The event system emits `internal/dispatch` (lines 75‑77), `internal/listener` (lines 62‑66), and `internal/update` hooks during normal operation. Plugins that listen to these internal events can unintentionally interfere with the framework's event flow if they modify arguments or return values.

**Warning:** Only hook into `internal/*` events if you understand the core dispatch loop, as improper handlers can break the `thisArg` resolution or listener execution order.

## Summary

- **Always dispose**: Store and call the function returned by `Context.on()` to prevent memory leaks during reloads.
- **Symbol equality**: Use consistent Symbol references for event names; inline Symbols create distinct keys.
- **Check thisArg**: Passing a Context as the first argument to `emit` filters listeners by scope.
- **Mind the order**: Use `{ prepend: true }` to execute listeners first, but understand the default append behavior.
- **Global caution**: `{ global: true }` affects all contexts; use only when cross‑plugin communication is intentional.
- **Respect contracts**: Call `next()` exactly once in waterfalls, and handle `AggregateError` in parallel dispatches.
- **Avoid internal interference**: Hooking into `internal/dispatch` or `internal/listener` can destabilize the event flow.

## Frequently Asked Questions

### Why do I see duplicate console logs after hot‑module reloading my plugin?

You are likely registering event listeners without storing and calling the dispose function returned by `ctx.on()`. During a hot reload, the old plugin instance's listeners remain active in the `_hooks` array while the new instance registers additional listeners, causing both to fire. Always store the dispose function and call it in your plugin's `dispose` lifecycle hook.

### What is the difference between `ctx.emit('event')` and `ctx.emit(ctx, 'event')`?

The first argument to `emit` is interpreted as a `thisArg` (lines 73‑75 in [`packages/core/src/events.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/events.ts)). When you pass a Context instance, Cordis applies `Context.filter` to determine which listeners should receive the event. Passing `ctx` limits the dispatch to listeners registered on that specific context or its children, while omitting it broadcasts to all applicable listeners.

### How do I ensure my event listener executes before others registered on the same context?

Pass `{ prepend: true }` as the third argument to `Context.on()`. This inserts the listener at the beginning of the internal `_hooks` array (lines 35‑38), ensuring it runs before listeners added without this option. This is critical when chaining configuration updates via `internal/update` hooks.

### Why does my waterfall event stop executing after the first listener?

Each listener in a `ctx.waterfall()` dispatch must call the `next()` callback exactly once (lines 25‑27). If your listener logic completes without invoking `next()`, the chain aborts silently. If you call `next()` multiple times, the system throws an error. Ensure your waterfall handlers explicitly invoke `next()` to pass control to subsequent listeners.