Skip to content
aviral gupta

// B5.1 · ~31 min · Beginner

The EventEmitter

After this lesson you can create and extend an EventEmitter, add and remove listeners, predict when they run, handle the 'error' event and wait for an event with a promise.

Lesson 1 of 5 in B5 Events, input and first tests

Start of the module

You will be able to

  • Add listeners with on and once, emit events with arguments, and remove listeners with off
  • Predict when listeners run and what emit returns, and handle the 'error' event
  • Extend EventEmitter in a class, await events.once(), and keep listeners from piling up
  1. Warm-up · Activity 1 of 7

    Warm-up from B4.2: you wrote process.on('unhandledRejection', listener). What does this program print?

    import {EventEmitter} from 'node:events';
    
    console.log(process instanceof EventEmitter, typeof process.on);
  2. Predict · Activity 2 of 7

    Predict before you read on. In which order are the lines printed?

    import {EventEmitter} from 'node:events';
    
    const door = new EventEmitter();
    door.on('open', () => console.log('listener 1'));
    door.on('open', () => console.log('listener 2'));
    console.log('before emit');
    door.emit('open');
    console.log('after emit');
  3. Practice · Activity 3 of 7

    door emits open twice. Fill in the method, so that first visitor is printed only the first time.

    door.____('open', () => console.log('first visitor'));
    door.('open', () => console.log('first visitor'));
  4. Practice · Activity 4 of 7

    Match each EventEmitter method to what it does.

  5. Practice · Activity 5 of 7

    What does this program print?

    import {EventEmitter} from 'node:events';
    
    const shop = new EventEmitter();
    shop.on('sale', (item, price) => console.log(item, price));
    console.log(shop.emit('sale', 'tea', 3));
    console.log(shop.emit('refund', 'tea'));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The same function count is added twice to an EventEmitter and twice to an EventTarget, the global that browsers have too. Each then gets one event. What does the program print?

    import {EventEmitter} from 'node:events';
    
    let calls = 0;
    const count = () => calls++;
    
    const emitter = new EventEmitter();
    emitter.on('ping', count);
    emitter.on('ping', count);
    emitter.emit('ping');
    
    const target = new EventTarget();
    target.addEventListener('ping', count);
    target.addEventListener('ping', count);
    target.dispatchEvent(new Event('ping'));
    
    console.log(calls);
  7. Apply · Activity 7 of 7

    Mini-task. Write a class Countdown that extends EventEmitter. Its method start(from, ms) emits 'tick' with the number left every ms milliseconds, from from down to 1, then stops its interval and emits 'end'. In the same file, print each tick, wait for the end with once from node:events, and print lift-off. Run it with node main.js.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A job that reports its progress

Job extends EventEmitter and emits progress after each step, then done, or error when it fails. The program listens with on and once, waits for done with once from node:events, handles the error event of a broken job, and finally removes a listener with off. Watch which listeners are left, and what emit returns before and after off.

main.js

import {EventEmitter, once} from 'node:events';

// A job that reports its progress as events.
class Job extends EventEmitter {
  constructor(name, steps) {
    super(); // sets up the emitter; must come before this
    this.name = name;
    this.steps = steps;
  }

  async run() {
    for (let step = 1; step <= this.steps; step++) {
      await new Promise((resolve) => setTimeout(resolve, 10));
      this.emit('progress', step, this.steps);
    }
    if (this.name === 'broken') this.emit('error', new Error(this.name + ' failed'));
    else this.emit('done', this.name);
  }
}

const backup = new Job('backup', 3);
backup.on('progress', (step, total) => console.log('progress ' + step + '/' + total));
backup.once('progress', () => console.log('(only after the first progress event)'));
backup.run();
const [name] = await once(backup, 'done'); // a promise for the next 'done'
console.log('done:', name);
console.log('progress listeners left:', backup.listenerCount('progress'));

const broken = new Job('broken', 1);
broken.on('error', (error) => console.log('error event:', error.message));
await broken.run();

// Remove a listener you no longer need, with the same function.
const bus = new EventEmitter();
const onStop = () => console.log('stopping');
bus.on('stop', onStop);
console.log('emit returns', bus.emit('stop'));
bus.off('stop', onStop);
console.log('emit returns', bus.emit('stop'));

Run it with

node main.js

Output

progress 1/3
(only after the first progress event)
progress 2/3
progress 3/3
done: backup
progress listeners left: 1
error event: broken failed
stopping
emit returns true
emit returns false
  • The once listener ran after the first progress line only, and was removed: one progress listener is left.
  • await once(backup, 'done') gave the array of done's arguments; [name] took the first.
  • The broken job emitted error, and because there was a listener, the program went on.
  • After off, emit found no listener and returned false.

Exercises

Exercise 1 of 2

A mini emitter

node:events is Node-only, so write the core of it yourself; it runs in the browser too. createEmitter() returns {on, once, off, emit}. emit(name, ...args) calls each listener of name with args, in the order they were added, and returns true if there were any, false otherwise. off removes one instance of the function. once's listener runs on the next emit only. A listener that removes itself while emit runs must not make the next one be skipped.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    In off, find the function with list.lastIndexOf(fn) and remove it with list.splice(index, 1), only when the index is not -1.

  2. Hint 2

    In emit, loop over a copy, [...(listeners.get(name) ?? [])], call fn(...args), and return copy.length > 0.

  3. Hint 3

    For once, add a wrapper (...args) => { off(name, wrapper); fn(...args); } with on, so the wrapper removes itself first.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// A small emitter with the rules of EventEmitter's on, once, off and emit.
export function createEmitter() {
  const listeners = new Map(); // event name -> array of functions

  function on(name, fn) {
    if (!listeners.has(name)) listeners.set(name, []);
    listeners.get(name).push(fn);
  }

  function off(name, fn) {
    const list = listeners.get(name) ?? [];
    const index = list.lastIndexOf(fn); // the most recently added instance
    if (index !== -1) list.splice(index, 1);
  }

  function once(name, fn) {
    const wrapper = (...args) => {
      off(name, wrapper);
      fn(...args);
    };
    on(name, wrapper);
  }

  function emit(name, ...args) {
    const list = [...(listeners.get(name) ?? [])]; // a copy: changes wait for the next emit
    for (const fn of list) fn(...args);
    return list.length > 0;
  }

  return {on, once, off, emit};
}

const door = createEmitter();
door.on('open', (who) => console.log('hello', who));
door.once('open', () => console.log('first visitor'));
console.log(door.emit('open', 'Ada'), door.emit('open', 'Lin'), door.emit('close'));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// A small emitter with the rules of EventEmitter's on, once, off and emit.
export function createEmitter() {
  const listeners = new Map(); // event name -> array of functions

  function on(name, fn) {
    if (!listeners.has(name)) listeners.set(name, []);
    listeners.get(name).push(fn);
  }

  function off(name, fn) {}

  function once(name, fn) {
    on(name, fn);
  }

  function emit(name, ...args) {
    for (const fn of listeners.get(name) ?? []) fn();
  }

  return {on, once, off, emit};
}

const door = createEmitter();
door.on('open', (who) => console.log('hello', who));
door.once('open', () => console.log('first visitor'));
console.log(door.emit('open', 'Ada'), door.emit('open', 'Lin'), door.emit('close'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createEmitter} from './main.js';

test('listeners run in the order they were added, with the arguments of emit', () => {
  const e = createEmitter();
  const seen = [];
  e.on('sale', (item, price) => seen.push('first ' + item + ' ' + price));
  e.on('sale', (item) => seen.push('second ' + item));
  e.emit('sale', 'tea', 3);
  assert.deepEqual(seen, ['first tea 3', 'second tea'], `the listeners saw ${JSON.stringify(seen)}`);
});

test('emit returns true with listeners and false without', () => {
  const e = createEmitter();
  e.on('sale', () => {});
  assert.equal(e.emit('sale'), true, 'emit for an event with a listener should return true');
  assert.equal(e.emit('refund'), false, 'emit for an event without listeners should return false');
});

test('off removes one instance of the function', () => {
  const e = createEmitter();
  let calls = 0;
  const count = () => calls++;
  e.on('ping', count);
  e.on('ping', count);
  e.off('ping', count);
  e.emit('ping');
  assert.equal(calls, 1, `count ran ${calls} times after two on and one off; expected 1`);
  e.off('ping', count);
  assert.equal(e.emit('ping'), false, 'after removing both instances, emit should return false');
});

test('a once listener runs on the next emit only', () => {
  const e = createEmitter();
  let calls = 0;
  e.once('ring', (n) => (calls += n));
  assert.equal(e.emit('ring', 5), true, 'the first emit should find the once listener');
  assert.equal(e.emit('ring', 5), false, 'the second emit should find no listener');
  assert.equal(calls, 5, `the once listener added up to ${calls}; expected 5`);
});

test('a listener that removes itself does not make the next one be skipped', () => {
  const e = createEmitter();
  const seen = [];
  const first = () => {
    seen.push('first');
    e.off('open', first);
  };
  e.on('open', first);
  e.on('open', () => seen.push('second'));
  e.emit('open');
  assert.deepEqual(seen, ['first', 'second'], `the listeners that ran were ${JSON.stringify(seen)}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A kettle that emits events

Kettle extends EventEmitter and starts at 20 degrees. Write heat(target): raise the temperature in steps of 20, never past target, and emit 'temperature' with each new value; when it reaches 100, emit 'boiled'. A target above 100 emits 'error' with a RangeError and does not heat. Without an error listener, that error is thrown. Run node main.js, then node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Check target > 100 first: this.emit("error", new RangeError(...)), then return.

  2. Hint 2

    Loop while (this.temperature < target), set this.temperature = Math.min(this.temperature + 20, target), then this.emit("temperature", this.temperature).

  3. Hint 3

    After the loop, if this.temperature === 100, this.emit("boiled"). An emit("error") with no listener throws by itself; you write no throw.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {EventEmitter} from 'node:events';

// A kettle that reports what it does as events.
export class Kettle extends EventEmitter {
  constructor() {
    super();
    this.temperature = 20;
  }

  // Heats in steps of 20 degrees up to target, with a 'temperature' event for each
  // new value, then 'boiled' at 100. Above 100: an 'error' event, and no heating.
  heat(target) {
    if (target > 100) {
      this.emit('error', new RangeError('cannot heat above 100, asked for ' + target));
      return;
    }
    while (this.temperature < target) {
      this.temperature = Math.min(this.temperature + 20, target);
      this.emit('temperature', this.temperature);
    }
    if (this.temperature === 100) this.emit('boiled');
  }
}

const kettle = new Kettle();
kettle.on('temperature', (degrees) => console.log(degrees + ' degrees'));
kettle.once('boiled', () => console.log('boiled'));
kettle.on('error', (error) => console.log('error:', error.message));
kettle.heat(100);
kettle.heat(120);
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {EventEmitter} from 'node:events';

// A kettle that reports what it does as events.
export class Kettle extends EventEmitter {
  constructor() {
    super();
    this.temperature = 20;
  }

  // Heats in steps of 20 degrees up to target, with a 'temperature' event for each
  // new value, then 'boiled' at 100. Above 100: an 'error' event, and no heating.
  heat(target) {
    this.temperature = target;
  }
}

const kettle = new Kettle();
kettle.on('temperature', (degrees) => console.log(degrees + ' degrees'));
kettle.once('boiled', () => console.log('boiled'));
kettle.on('error', (error) => console.log('error:', error.message));
kettle.heat(100);
kettle.heat(120);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {Kettle} from './main.js';

test('heat(80) emits temperature 40, 60 and 80', () => {
  const kettle = new Kettle();
  const seen = [];
  kettle.on('temperature', (degrees) => seen.push(degrees));
  kettle.heat(80);
  assert.deepEqual(seen, [40, 60, 80], `the temperature events were ${JSON.stringify(seen)}`);
});

test('steps never go past the target', () => {
  const kettle = new Kettle();
  const seen = [];
  kettle.on('temperature', (degrees) => seen.push(degrees));
  kettle.heat(50);
  assert.deepEqual(seen, [40, 50], `the temperature events were ${JSON.stringify(seen)}`);
});

test('boiled is emitted once, when 100 is reached', () => {
  const kettle = new Kettle();
  let boiled = 0;
  kettle.on('boiled', () => boiled++);
  kettle.heat(60);
  assert.equal(boiled, 0, 'boiled should not be emitted at 60 degrees');
  kettle.heat(100);
  assert.equal(boiled, 1, `boiled was emitted ${boiled} times; expected 1`);
});

test('above 100, an error event with a RangeError, and no heating', () => {
  const kettle = new Kettle();
  const errors = [];
  kettle.on('error', (error) => errors.push(error));
  kettle.heat(120);
  assert.equal(errors.length, 1, `there were ${errors.length} error events; expected 1`);
  assert.ok(errors[0] instanceof RangeError, 'the error event should carry a RangeError');
  assert.equal(kettle.temperature, 20, `the temperature is ${kettle.temperature}; it should stay 20`);
});

test('without an error listener, heat(120) throws the RangeError', () => {
  assert.throws(() => new Kettle().heat(120), RangeError, 'heat(120) with no error listener should throw a RangeError');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Emitting 'error' with nobody listening

import {EventEmitter} from 'node:events';

const job = new EventEmitter();
job.on('done', () => console.log('done'));
job.emit('error', new Error('disk full'));
console.log('still running');

What Node.js prints

Error: disk full

Why, and the fix

An error event without an error listener is thrown: Node.js prints the error with its stack and the process ends, so still running never appears. Add a listener before anything can fail: job.on('error', (error) => console.error('job failed:', error.message)). Then the error is just an event, and the program carries on.

Forgetting super() in the constructor

import {EventEmitter} from 'node:events';

class Job extends EventEmitter {
  constructor(name) {
    this.name = name;
  }
}

const job = new Job('backup');
job.emit('start');

What Node.js prints

ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Why, and the fix

A class that extends another must call super() in its constructor before it touches this; super() is what sets up the emitter. Write super(); as the first line, then this.name = name.

Using this in an arrow-function listener

import {EventEmitter} from 'node:events';

const door = new EventEmitter();
door.on('open', () => {
  console.log('listeners:', this.listenerCount('open'));
});
door.emit('open');

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'listenerCount')

Why, and the fix

In an ordinary function listener, this is the emitter. An arrow function takes this from where it was written, here the top level of a module, where this is undefined. Use function () { … } when you need this, or simply name the emitter: door.listenerCount("open").

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Listen, emit, stop listening

import {EventEmitter} from 'node:events' and create one with new EventEmitter(). emitter.on('done', listener) adds a listener for the event named done; emitter.emit('done', a, b) calls every listener for done with the arguments a and b. once adds a listener that is removed after its first call. off('done', listener) removes one instance again, and it needs the same function: an arrow function written out a second time is a different function, so nothing is removed. on does not check for duplicates: the same function added twice is called twice. In an ordinary function listener, this is the emitter; in an arrow function it is not.

Synchronous, in order, and the 'error' event

emit is not asynchronous: it calls the listeners at once, one after another in the order they were added, and returns only when all have run. It returns true if the event had listeners, false otherwise. A listener that throws stops the rest, and the error comes out of emit, where try/catch can catch it. The event named error is special. If nobody listens for it, emit('error', err) throws err: Node.js prints it with a stack trace and the process ends. So every emitter that can fail needs emitter.on('error', …). Node's EventTarget global differs: it adds the same listener only once per type, and has no special error event.

Your own emitter, a promise, and leaks

class Job extends EventEmitter makes every job an emitter; its methods call this.emit('progress', …). A constructor must call super() before it uses this. To wait for one event in async code, use once from node:events: const [name] = await once(job, 'done') fulfils with the array of the event's arguments, and rejects if the emitter emits error first. Listeners stay until you remove them. Adding one per request and never calling off makes the list grow: by default, the 11th listener for one event prints a MaxListenersExceededWarning, a possible memory leak, though the listener is still added. Remove what you add, or use once; raise the limit with setMaxListeners only when you mean it.

Sources

Last reviewed October 4, 2026