VOOZH about

URL: https://dev.to/congar97/mastering-javascript-event-systems-build-a-production-grade-file-watcher-from-scratch-54hl

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


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'  []
}

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

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

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)

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

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)
 }
}

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
 }

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
 }

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

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]

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 ✅
 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
 }

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: {}

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
 }
}

👁 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

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

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

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$/

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))
 }
}

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
 }
}

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
}

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 ✅

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()
 }
}

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
 }
}
// 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)
})

.nodemonrc.json example:

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

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) })

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