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

Top comments (0)