DEV Community

Cover image for Mastering JavaScript Event Systems: Build a Production-Grade File Watcher from Scratch
Mohammed Abdelhady
Mohammed Abdelhady

Posted on

Mastering JavaScript Event Systems: Build a Production-Grade File Watcher from Scratch

Mastering JavaScript Event Systems: Build a Production-Grade File Watcher from Scratch

EventEmitter is the backbone of Node.js streams, Express middleware, browser DOM events, and React's synthetic event system. Building one from scratch makes every async pattern click. You'll understand why removeEventListener requires the same function reference, why event order matters, and how memory leaks from undisposed listeners happen.

Here's what this covers:

Concept What it does
Listener registry A Map storing event names to arrays of listener functions
on(event, fn) Registers a listener for an event
off(event, fn) Removes a specific listener by its reference
emit(event, ...args) Calls every registered listener synchronously, in order
once(event, fn) Wraps fn in a self-removing wrapper that fires exactly once

Three ideas the whole system rests on

Before writing any code, it helps to nail down the concepts. The implementation will make a lot more sense once these feel obvious.

The data structure: a Map of arrays

listeners = {
  'change'  [fn1, fn2, fn3],
  'error'   [fn4],
  'close'   []
}
Enter fullscreen mode Exit fullscreen mode

That's it. Every method (on, off, emit, once) just reads or writes this single Map. The reason to use Map instead of a plain object is that plain objects carry prototype baggage:

const obj = {}
obj['constructor'] // Already exists. Prototype collision.
obj['__proto__']   // Silently breaks things.

const map = new Map<string, Listener[]>()
map.get('constructor') // undefined, no surprises
Enter fullscreen mode Exit fullscreen mode

Reference equality: the rule that governs everything

JavaScript compares functions by memory address, not by their code. Two arrow functions with identical bodies are different objects at different addresses:

const a = () => console.log('hello')
const b = () => console.log('hello') // identical body, different object

a === b // false
a === a // true
Enter fullscreen mode Exit fullscreen mode

This is why off(event, fn) requires the exact same function object that was passed to on(). A look-alike won't match:

// Never removes the listener. Different arrow function each time.
emitter.on('change', () => doSomething())
emitter.off('change', () => doSomething())

// Works. Same reference.
const handler = () => doSomething()
emitter.on('change', handler)
emitter.off('change', handler)
Enter fullscreen mode Exit fullscreen mode

Synchronous emission

Listeners fire one by one in registration order, with no async gaps:

emitter.on('data', () => console.log('A'))
emitter.on('data', () => console.log('B'))
emitter.on('data', () => console.log('C'))

emitter.emit('data')
// A
// B
// C
Enter fullscreen mode Exit fullscreen mode

Building the EventEmitter

on(): registering a listener

type Listener = (...args: unknown[]) => void

class EventEmitter {
  private listeners = new Map<string, Listener[]>()

  on(event: string, fn: Listener): this {
    const existing = this.listeners.get(event) ?? []

    // Spread instead of push. Creates a fresh array rather than mutating.
    // If emit() is iterating while on() runs in a callback, mutation causes bugs.
    this.listeners.set(event, [...existing, fn])

    return this // enables chaining: ee.on('a', fn1).on('b', fn2)
  }
}
Enter fullscreen mode Exit fullscreen mode

off(): removing a listener

  off(event: string, fn: Listener): this {
    const existing = this.listeners.get(event)
    if (!existing) return this

    const updated = existing.filter(l => l !== fn)

    // Delete the key entirely when no listeners remain.
    // Leaving empty arrays in the Map is a slow memory leak in long-running servers.
    if (updated.length === 0) {
      this.listeners.delete(event)
    } else {
      this.listeners.set(event, updated)
    }

    return this
  }
Enter fullscreen mode Exit fullscreen mode

emit(): calling all listeners

  emit(event: string, ...args: unknown[]): boolean {
    const fns = this.listeners.get(event)
    if (!fns || fns.length === 0) return false

    // Snapshot the array before iterating.
    // A listener can call off() on itself during the loop and mutate the array.
    // The snapshot keeps iteration stable regardless of what listeners do.
    fns.slice().forEach(fn => fn(...args))

    return true
  }
Enter fullscreen mode Exit fullscreen mode

Without the snapshot, once() wrappers cause skipped listeners:

// Without snapshot
const listeners = [A, B, C]
// B calls off() inside its handler, listeners becomes [A, C]
// forEach skips C because the index shifted

// With snapshot
const snapshot = [A, B, C] // copied before the loop
// B calls off(), original becomes [A, C]
// snapshot is still [A, B, C], so C fires correctly
Enter fullscreen mode Exit fullscreen mode

once(): the self-removing listener

This is the trickiest part. The goal is a listener that fires exactly once, then removes itself, including if off(originalFn) is called before it ever fires.

The problem is that once() can't register fn directly. It has to register a wrapper that calls fn then removes itself. But that means fn is never in the Map, so off(fn) won't find anything:

What we want to register:   ee.once('data', myFn)
What actually goes in the Map: wrapper (not myFn)

listeners['data'] = [wrapper]
Enter fullscreen mode Exit fullscreen mode

The fix is a second Map that bridges the original function to its wrapper:

Map 1: listeners  →  event → [wrapper, ...]
Map 2: wrappers   →  myFn  → wrapper

off(event, myFn)
  → wrappers.get(myFn) finds wrapper
  → removes wrapper from listeners ✅
Enter fullscreen mode Exit fullscreen mode
  private wrappers = new Map<Listener, Listener>()

  once(event: string, fn: Listener): this {
    const wrapper: Listener = (...args) => {
      fn(...args)              // call the original handler
      this.off(event, wrapper) // remove wrapper from listeners
      this.wrappers.delete(fn) // clean up the bridge to prevent memory leak
    }

    this.wrappers.set(fn, wrapper) // register the bridge
    return this.on(event, wrapper) // store wrapper, not fn
  }

  // Updated off() that resolves originalFn to wrapper automatically
  off(event: string, fn: Listener): this {
    const target = this.wrappers.get(fn) ?? fn

    const existing = this.listeners.get(event)
    if (!existing) return this

    const updated = existing.filter(l => l !== target)
    if (updated.length === 0) this.listeners.delete(event)
    else this.listeners.set(event, updated)

    this.wrappers.delete(fn)
    return this
  }
Enter fullscreen mode Exit fullscreen mode

Full call flow:

1. ee.once('data', myFn)
   wrappers:  { myFn → wrapper }
   listeners: { 'data' → [wrapper] }

2a. Event fires
    wrapper runs → calls myFn → removes wrapper → cleans bridge
    wrappers:  {}
    listeners: {}

2b. Early removal: ee.off('data', myFn)
    off resolves myFn to wrapper via wrappers Map
    removes wrapper from listeners
    wrappers:  {}
    listeners: {}
Enter fullscreen mode Exit fullscreen mode

The complete EventEmitter

type Listener = (...args: unknown[]) => void

export class EventEmitter {
  private listeners = new Map<string, Listener[]>()
  private wrappers  = new Map<Listener, Listener>()

  on(event: string, fn: Listener): this {
    const existing = this.listeners.get(event) ?? []
    this.listeners.set(event, [...existing, fn])
    return this
  }

  off(event: string, fn: Listener): this {
    const target   = this.wrappers.get(fn) ?? fn
    const existing = this.listeners.get(event)
    if (!existing) return this
    const updated = existing.filter(l => l !== target)
    if (updated.length === 0) this.listeners.delete(event)
    else this.listeners.set(event, updated)
    this.wrappers.delete(fn)
    return this
  }

  emit(event: string, ...args: unknown[]): boolean {
    const fns = this.listeners.get(event)
    if (!fns || fns.length === 0) return false
    fns.slice().forEach(fn => fn(...args))
    return true
  }

  once(event: string, fn: Listener): this {
    const wrapper: Listener = (...args) => {
      fn(...args)
      this.off(event, wrapper)
      this.wrappers.delete(fn)
    }
    this.wrappers.set(fn, wrapper)
    return this.on(event, wrapper)
  }

  removeAllListeners(event?: string): this {
    if (event !== undefined) {
      this.listeners.delete(event)
    } else {
      this.listeners.clear()
      this.wrappers.clear() // must clear both, wrappers holds references too
    }
    return this
  }

  listenerCount(event: string): number {
    return this.listeners.get(event)?.length ?? 0
  }
}
Enter fullscreen mode Exit fullscreen mode

Interactive Visualization

Quick smoke test:

const ee = new EventEmitter()

ee.on('ping', (id) => console.log(`pong ${id}`))
  .once('hello', ()  => console.log('hello, once!'))

ee.emit('ping', 1)   // pong 1
ee.emit('hello')     // hello, once!
ee.emit('hello')     // (nothing, removed after first fire)
ee.emit('ping', 2)   // pong 2
Enter fullscreen mode Exit fullscreen mode

The project: mini-nodemon

Now we wire the EventEmitter into something real. A CLI file watcher that restarts a child process whenever your source files change, using the same architecture as nodemon itself.

mini-nodemon/
├── src/
│   ├── core/
│   │   └── event-emitter.ts     ← EventEmitter we just built
│   ├── watcher/
│   │   ├── glob-matcher.ts      ← converts src/**/*.ts to regex
│   │   └── file-watcher.ts      ← fs.watch + glob filter, emits 'change'
│   ├── runner/
│   │   ├── debounce.ts          ← delays a call until input stops
│   │   └── process-runner.ts   ← spawns child process, restarts on 'change'
│   └── cli/
│       ├── config.ts            ← reads .nodemonrc.json
│       └── index.ts             ← wires everything together
└── tests/
    └── integration.test.ts
Enter fullscreen mode Exit fullscreen mode

How the pieces connect:

fs.watch (OS)
    │  raw change events (fires 2-3x per save)
    ▼
FileWatcher (extends EventEmitter)
    │  emits 'change' (glob-filtered)
    ▼
ProcessRunner
    │  debounced restart (fires once per save)
    ▼
child_process.spawn
    │  runs your command
    ▼
terminal output
Enter fullscreen mode Exit fullscreen mode

GlobMatcher: pattern to regex

src/**/*.ts is glob syntax. The OS doesn't understand it, so we convert it to a regex:

export function globToRegex(pattern: string): RegExp {
  const escaped = pattern
    // Escape regex special chars first, before adding our own
    .replace(/[.+^${}()|[\]\\]/g, '\\$&')

    // Hide ** temporarily so the next step doesn't touch it
    .replace(/\*\*/g, '<<<DOUBLE>>>')

    // * matches anything within one segment (not /)
    .replace(/\*/g, '[^/]*')

    // ** matches any number of segments including /
    .replace(/<<<DOUBLE>>>/g, '.*')

    // ? matches exactly one character (not /)
    .replace(/\?/g, '[^/]')

  return new RegExp(`^${escaped}$`)
}

// globToRegex('src/**/*.ts') → /^src\/.*\/[^/]*\.ts$/
// globToRegex('*.json')      → /^[^/]*\.json$/
Enter fullscreen mode Exit fullscreen mode

The placeholder trick for ** is necessary because replacing * first would turn ** into [^/]*[^/]*, which is technically valid but semantically wrong.

export class GlobMatcher {
  private patterns: RegExp[]

  constructor(globs: string[]) {
    this.patterns = globs.map(globToRegex)
  }

  matches(filePath: string): boolean {
    return this.patterns.some(re => re.test(filePath))
  }
}
Enter fullscreen mode Exit fullscreen mode

FileWatcher: OS events to filtered EventEmitter events

import fs   from 'fs'
import path from 'path'
import { EventEmitter } from '../core/event-emitter'
import { GlobMatcher  } from './glob-matcher'

export class FileWatcher extends EventEmitter {
  private matcher: GlobMatcher
  private dir: string
  private watcher: fs.FSWatcher | null = null

  constructor({ patterns, dir }: { patterns: string[], dir: string }) {
    super()
    this.matcher = new GlobMatcher(patterns)
    this.dir     = path.resolve(dir)
  }

  async start(): Promise<void> {
    this.watcher = fs.watch(
      this.dir,
      { recursive: true }, // watches all subdirectories
      (eventType, filename) => {
        if (!filename) return

        // Normalize Windows backslashes before matching
        const rel = filename.replace(/\\/g, '/')

        if (this.matcher.matches(rel)) {
          this.emit('change', path.join(this.dir, rel))
        }
      }
    )
  }

  async stop(): Promise<void> {
    this.watcher?.close()
    this.watcher = null
  }
}
Enter fullscreen mode Exit fullscreen mode

FileWatcher extends EventEmitter rather than containing one so consumers call watcher.on('change', fn) directly. Containing one would require watcher.emitter.on(...), which is an unnecessary extra layer.

Debounce: collapse rapid events into one

fs.watch fires 2 to 4 events per single file save. Without debouncing, your process restarts multiple times per keystroke:

export function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
  let timer: ReturnType<typeof setTimeout> | null = null

  return ((...args: unknown[]) => {
    if (timer) clearTimeout(timer) // every call resets the clock
    timer = setTimeout(() => {
      fn(...args)
      timer = null
    }, ms) // only executes after ms of silence
  }) as T
}
Enter fullscreen mode Exit fullscreen mode

Timeline for a single file save with delay = 300ms:

t=0ms   fs.watch fires (event 1)  → timer set for t=300ms
t=10ms  fs.watch fires (event 2)  → timer reset for t=310ms
t=20ms  fs.watch fires (event 3)  → timer reset for t=320ms
t=320ms silence                   → fn() runs once ✅
Enter fullscreen mode Exit fullscreen mode

ProcessRunner: subscribe to changes, manage child process

import { spawn, ChildProcess } from 'child_process'
import { debounce            } from './debounce'
import type { FileWatcher    } from '../watcher/file-watcher'

export class ProcessRunner {
  private child:        ChildProcess | null = null
  private restartCount: number = 0
  private command:      string
  private watcher:      FileWatcher
  private delay:        number

  constructor({ command, watcher, delay = 300 }: {
    command: string
    watcher: FileWatcher
    delay?: number
  }) {
    this.command = command
    this.watcher = watcher
    this.delay   = delay
  }

  start(): void {
    this.spawn()

    const debouncedRestart = debounce(() => this.restart(), this.delay)

    this.watcher.on('change', (filePath: unknown) => {
      console.log(`\x1b[33m[mini-nodemon]\x1b[0m Change detected: ${filePath}`)
      debouncedRestart()
    })
  }

  private spawn(): void {
    this.child = spawn(this.command, {
      shell: true,     // allows full shell commands like 'node dist/index.js --port=3000'
      stdio: 'inherit' // child stdout/stderr goes directly to your terminal
    })

    this.child.on('exit', (code) => {
      if (code !== null && code !== 0) {
        console.log(`\x1b[31m[mini-nodemon]\x1b[0m Process exited with code ${code}`)
      }
    })
  }

  private restart(): void {
    this.restartCount++
    console.log(`\x1b[32m[mini-nodemon]\x1b[0m Restarting... (restart #${this.restartCount})`)

    // Kill first, then spawn. If you spawn first, two instances run simultaneously.
    this.child?.kill('SIGTERM')
    this.spawn()
  }

  stop(): void {
    this.child?.kill('SIGTERM')
    this.watcher.stop()
  }
}
Enter fullscreen mode Exit fullscreen mode

ANSI color reference:

Code Color Used for
\x1b[31m Red Exit errors
\x1b[32m Green Restarts
\x1b[33m Yellow Change detection
\x1b[36m Cyan Startup messages
\x1b[0m Reset End of color

Config and CLI entry point

// src/cli/config.ts
import fs   from 'fs'
import path from 'path'

interface NodaemonConfig {
  watch?:    string[]
  patterns?: string[]
  delay?:    number
  ignore?:   string[]
}

export function loadConfig(cwd: string): NodaemonConfig {
  const configPath = path.join(cwd, '.nodemonrc.json')
  if (!fs.existsSync(configPath)) return {}
  try {
    return JSON.parse(fs.readFileSync(configPath, 'utf8')) as NodaemonConfig
  } catch {
    return {} // malformed JSON, fall back to defaults silently
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/cli/index.ts
import { FileWatcher   } from '../watcher/file-watcher'
import { ProcessRunner } from '../runner/process-runner'
import { loadConfig    } from './config'

// argv: ['node', 'mini-nodemon', './src', 'node dist/index.js', '--delay=500']
const args     = process.argv.slice(2)
const watchDir = args[0] ?? './'
const command  = args[1] ?? 'node index.js'

const delayArg = args.find(a => a.startsWith('--delay='))
const delay    = delayArg ? parseInt(delayArg.split('=')[1]) : undefined

// CLI args take precedence over file config
const config     = loadConfig(process.cwd())
const patterns   = config.patterns ?? ['**/*.ts', '**/*.js', '**/*.json']
const finalDelay = delay ?? config.delay ?? 300

const watcher = new FileWatcher({ patterns, dir: watchDir })
const runner  = new ProcessRunner({ command, watcher, delay: finalDelay })

console.log(`\x1b[36m[mini-nodemon]\x1b[0m Watching ${watchDir} for changes...`)
console.log(`\x1b[36m[mini-nodemon]\x1b[0m Running: ${command}`)

// Watcher must start before runner to avoid missing events during startup
watcher.start().then(() => runner.start())

// Ctrl+C: graceful shutdown so the child process isn't orphaned
process.on('SIGINT', () => {
  console.log('\n\x1b[33m[mini-nodemon]\x1b[0m Shutting down...')
  runner.stop()
  process.exit(0)
})
Enter fullscreen mode Exit fullscreen mode

.nodemonrc.json example:

{
  "patterns": ["**/*.ts", "**/*.env"],
  "delay": 500,
  "ignore": ["node_modules/**", "dist/**"]
}
Enter fullscreen mode Exit fullscreen mode

Integration tests

The tests use a real temp directory and real fs.watch with no mocks, verifying the full pipeline end-to-end:

import { FileWatcher } from '../src/watcher/file-watcher'
import { debounce    } from '../src/runner/debounce'
import fs   from 'fs'
import os   from 'os'
import path from 'path'

// Converts emitter.once() into a Promise with a timeout.
// Rejects if the event doesn't fire within timeoutMs.
async function waitForEvent(
  emitter: FileWatcher,
  event: string,
  timeoutMs = 1000
): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(
      () => reject(new Error(`Timeout: '${event}' never fired`)),
      timeoutMs
    )
    emitter.once(event, (data: unknown) => {
      clearTimeout(timer)
      resolve(data)
    })
  })
}

async function runTests() {
  // Test 1: matching file triggers 'change'
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-test-'))
  const watcher = new FileWatcher({ patterns: ['**/*.ts'], dir: tmpDir })
  await watcher.start()

  // fs.watch needs a short moment to initialize before detecting events
  await new Promise(r => setTimeout(r, 50))

  const changePromise = waitForEvent(watcher, 'change')
  fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'const x = 1')
  const changedPath = await changePromise as string

  console.assert(changedPath.endsWith('test.ts'), `Got: ${changedPath}`)
  console.log('✓ FileWatcher emits change for matching files')

  // Test 2: non-matching files are ignored
  let jsChangeFired = false
  watcher.on('change', (p: unknown) => {
    if (String(p).endsWith('.js')) jsChangeFired = true
  })
  fs.writeFileSync(path.join(tmpDir, 'test.js'), 'const y = 2')
  await new Promise(r => setTimeout(r, 200))

  console.assert(!jsChangeFired, 'Should not emit for .js when pattern is **/*.ts')
  console.log('✓ GlobMatcher filters out non-matching files')

  // Test 3: debounce fires once on rapid calls
  let callCount = 0
  const debounced = debounce(() => callCount++, 50)
  debounced(); debounced(); debounced()
  await new Promise(r => setTimeout(r, 200))

  console.assert(callCount === 1, `Expected 1 call, got ${callCount}`)
  console.log('✓ debounce fires exactly once on rapid calls')

  await watcher.stop()
  fs.rmSync(tmpDir, { recursive: true })
  console.log('\n✅ All tests passed!')
}

runTests().catch(err => { console.error(err); process.exit(1) })
Enter fullscreen mode Exit fullscreen mode

Run with: npx ts-node tests/integration.test.ts

Test your knowledge

Q1: Why does off(event, fn) require the exact function reference?

  • [ ] JavaScript compares functions by their string source code
  • [ ] Two function objects are only equal if they share the same memory reference
  • [ ] TypeScript enforces reference equality for listener removal
  • [ ] The Map uses structural comparison for function keys

Q2: What is the purpose of snapshotting the listener array before iterating in emit()?

  • [ ] To improve performance by avoiding repeated Map lookups
  • [ ] To allow listeners to safely call off() on themselves during emission without causing array mutation bugs
  • [ ] To create a deep copy of function references
  • [ ] To enforce FIFO ordering of listeners

Q3: A once() listener and a regular on() listener for the same event will fire in emission order, with the listener registered first firing first. True or false?

Q4: Which data structure is most appropriate for the listener registry in a high-performance EventEmitter?

  • [ ] Plain object {}
  • [ ] Map<string, Listener[]>
  • [ ] Set<[string, Listener]>
  • [ ] Array of {event, fn} tuples

Answers

A1: Two function objects are only equal if they share the same memory reference. JavaScript's === for objects (including functions) is reference equality. Two arrow functions with identical bodies are different objects at different memory addresses. This is why addEventListener paired with removeEventListener using an anonymous function never actually removes the listener.

A2: To allow listeners to safely call off() on themselves during emission without causing array mutation bugs. If a listener calls this.off(event, fn) during the loop, as once() wrappers do, it mutates the array being iterated. Snapshotting with .slice() before the loop prevents skipping or double-calling other listeners.

A3: True. Both on() and once() push to the same listener array. Emission iterates in insertion order and the type of registration has no effect on ordering.

A4: Map<string, Listener[]>. It provides O(1) amortized get/set/delete and handles any string key without prototype collision risk. With plain objects, keys like 'constructor' or '__proto__' can silently conflict with existing properties.

Key takeaways

An EventEmitter is a telephone switchboard. on() plugs in a phone. off() unplugs it. emit() calls every phone on the line simultaneously. once() is a phone that self-destructs after one call. The switchboard knows which phones are on which lines, but you must keep the exact same phone reference to unplug it.

Rule Why it matters
EventEmitter = Map<string, Listener[]> Single data structure, four operations
off() requires reference equality Anonymous functions can never be removed
once() needs a wrapper and a reverse Map So off(originalFn) still works before first fire
Snapshot before emit() iteration once() wrappers mutate the array mid-loop
Delete empty event keys Prevents silent memory leaks in long-lived emitters
Debounce fs.watch events The OS fires 2 to 4 events per save, collapse them to one restart

Top comments (0)