What You'll Learn
- An EventEmitter maintains a map of event names to arrays of listener functions
- on(event, fn) registers a listener; off(event, fn) removes it by reference
- emit(event, ...args) synchronously calls every registered listener in registration order
- once(event, fn) wraps fn in a self-removing wrapper so it fires exactly one time
Why it matters: 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.
Listener Registry Pattern
The core data structure is a Map from event name to an array of listener functions. This gives O(1) lookup per event name and preserves insertion order for emission. All four public methods — on, off, emit, once — operate on this single Map.
// Listener Registry Pattern
type Listener = (...args: unknown[]) => void
class EventEmitter {
private listeners = new Map<string, Listener[]>() // event name → fns array
on(event: string, fn: Listener): this {
const existing = this.listeners.get(event) ?? []
this.listeners.set(event, [...existing, fn]) // append, don't mutate
return this // fluent API
}
off(event: string, fn: Listener): this {
const existing = this.listeners.get(event) ?? []
this.listeners.set(event, existing.filter(l => l !== fn)) // reference equality
return this
}
emit(event: string, ...args: unknown[]): boolean {
const fns = this.listeners.get(event)
if (!fns || fns.length === 0) return false
fns.forEach(fn => fn(...args)) // fires all listeners in order
return true
}
}
The once() Wrapper Pattern
once() must call the listener exactly once then remove itself. The trick is a wrapper function that (1) calls the original fn, (2) calls this.off() with itself as the reference. The wrapper must be stored so off() can find it by reference — which means you need to associate the original fn with its wrapper.
// The once() Wrapper Pattern
// Naive implementation — works but can't be removed early
once(event: string, fn: Listener): this {
const wrapper: Listener = (...args) => {
fn(...args)
this.off(event, wrapper) // self-removing
}
return this.on(event, wrapper) // registers wrapper, not fn
}
// Problem: ee.off('data', originalFn) won't work because
// we registered `wrapper`, not `fn`.
// Fix: track the wrapper → original mapping
private wrappers = new Map<Listener, Listener>() // bridges originalFn → wrapper
once(event: string, fn: Listener): this {
const wrapper: Listener = (...args) => {
fn(...args) // call the real handler
this.off(event, wrapper)
this.wrappers.delete(fn) // avoid memory leak
}
this.wrappers.set(fn, wrapper)
return this.on(event, wrapper)
}
off(event: string, fn: Listener): this {
// Check if fn has an associated wrapper
const toRemove = this.wrappers.get(fn) ?? fn // resolve to internal wrapper
const existing = this.listeners.get(event) ?? []
this.listeners.set(event, existing.filter(l => l !== toRemove))
return this
}
The Project: mini-nodemon
A production-grade CLI file watcher that uses your EventEmitter as its event bus — detecting file system changes with glob pattern matching and automatically restarting a child process. The same architecture powering the real nodemon.
Here's what makes it interesting:
- Glob pattern matching (src//.ts)*
- Debounced restart with configurable delay
- Colored console output with restart counter
- .nodemonrc.json config file support
Here's how the project is laid out:
mini-nodemon/
├── event-emitter.ts
├── glob-matcher.ts
├── file-watcher.ts
├── debounce.ts
├── process-runner.ts
├── config.ts
├── index.ts
├── integration.test.ts
├── package.json
├── tsconfig.json
Building It Step by Step
Step 1: Build the EventEmitter core
Implement the full EventEmitter — the event bus the whole project uses. Map-based listener registry, on/off/emit/once, removeAllListeners, listenerCount. This is the engine everything hooks into.
Working in src/core/event-emitter.ts
| Input | event: string, fn: (...args: unknown[]) => void |
| Output | this (for chaining); listener stored in Map<string, Listener[]> |
// Use Map<string, Listener[]>. The once() wrapper needs a wrappers Map so off(originalFn) can find the internal wrapper to remove it.
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() }
return this
}
listenerCount(event: string): number {
return this.listeners.get(event)?.length ?? 0
}
}
The EventEmitter is the event bus for the whole project. FileWatcher extends it to emit 'change'/'add'/'remove', ProcessRunner subscribes to those events to trigger restarts. The wrappers Map solves the once() removability problem — off(originalFn) resolves to the internal wrapper automatically.
Watch out for:
Snapshot with fns.slice() before iterating — once() wrappers remove themselves during emit which mutates the array
The wrappers Map bridges off(originalFn) → finds internal wrapper → removes it correctly
removeAllListeners() must also clear the wrappers map to avoid memory leaks
Test it: const ee = new EventEmitter(); ee.on('change', p => console.log(p)); ee.emit('change', 'src/index.ts') — should log the path
Step 2: Build GlobMatcher and FileWatcher
GlobMatcher converts patterns like src/**/*.ts into regex. FileWatcher uses Node's fs.watch recursively + GlobMatcher to filter paths, then emits 'change', 'add', 'remove' events on the EventEmitter bus.
Working in src/watcher/file-watcher.ts
| Input | patterns: string[] (e.g. ['src/**/*.ts']), dir: string |
| Output | EventEmitter emitting 'change' with file path when a matching file changes |
// Use `fs.watch(dir, { recursive: true })` to watch subdirectories. Raw fs.watch fires 2-3 times per save — you'll debounce this in the next step. For now, emit immediately and filter by glob.
// src/watcher/glob-matcher.ts
export function globToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '<<<DOUBLE>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<DOUBLE>>>/g, '.*')
.replace(/\?/g, '[^/]')
return new RegExp(`^${escaped}$`)
}
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))
}
}
// src/watcher/file-watcher.ts
import fs from 'fs'
import path from 'path'
import { EventEmitter } from '../core/event-emitter'
import { GlobMatcher } from './glob-matcher'
interface FileWatcherOptions {
patterns: string[]
dir: string
}
export class FileWatcher extends EventEmitter {
private matcher: GlobMatcher
private dir: string
private watcher: fs.FSWatcher | null = null
constructor({ patterns, dir }: FileWatcherOptions) {
super()
this.matcher = new GlobMatcher(patterns)
this.dir = path.resolve(dir)
}
async start(): Promise<void> {
this.watcher = fs.watch(this.dir, { recursive: true }, (eventType, filename) => {
if (!filename) return
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 directly — it IS the event bus for file events. GlobMatcher converts glob syntax to regex:
**matches any path segment depth,*matches within a segment. The{ recursive: true }option on fs.watch handles deep directory watching natively.
Watch out for:
fs.watch fires multiple events per single file save — the debouncer in step 3 will fix this
filename on Windows uses backslashes — normalize with replace(/\\/g, '/') before matching
recursive option is not available on all Linux systems — add a fallback note in README
Test it: const fw = new FileWatcher({ patterns: ['**/*.ts'], dir: './src' }); fw.on('change', console.log); fw.start() — edit a .ts file and see the log
Step 3: Build Debounce utility and ProcessRunner
debounce(fn, ms) delays execution — prevents restart storms when fs.watch fires multiple times per save. ProcessRunner spawns a child process, listens to FileWatcher 'change' events, kills and respawns on each change, logs restarts with color and a restart counter.
Working in src/runner/process-runner.ts
| Input | command: string (e.g. 'node dist/index.js'), watcher: FileWatcher |
| Output | Child process spawned; on watcher 'change' event: old process killed, new process spawned, restart count logged |
// Use `child_process.spawn` with `shell: true` to run arbitrary commands. Kill with `child.kill('SIGTERM')`. Track a restart counter and log with ANSI colors: `\x1b[32m` for green, `\x1b[0m` to reset.
// src/runner/debounce.ts
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)
timer = setTimeout(() => { fn(...args); timer = null }, ms)
}) as T
}
// src/runner/process-runner.ts
import { spawn, ChildProcess } from 'child_process'
import { debounce } from './debounce'
import type { FileWatcher } from '../watcher/file-watcher'
interface ProcessRunnerOptions {
command: string
watcher: FileWatcher
delay?: number
}
export class ProcessRunner {
private child: ChildProcess | null = null
private restartCount = 0
private command: string
private watcher: FileWatcher
private delay: number
constructor({ command, watcher, delay = 300 }: ProcessRunnerOptions) {
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, stdio: 'inherit' })
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})`)
this.child?.kill('SIGTERM')
this.spawn()
}
stop(): void {
this.child?.kill('SIGTERM')
this.watcher.stop()
}
}
debounce prevents restart storms: fs.watch fires 2-3 times per save, but the debounced restart only fires once after the last event within the delay window. SIGTERM is the graceful shutdown signal — the child process can clean up before exiting.
Watch out for:
stdio: 'inherit' passes child process output directly to parent terminal — without this you won't see the command output
Kill SIGTERM before spawning — if you spawn first, you'll have two processes running simultaneously
The debounce delay (300ms default) should be configurable — different projects need different tolerances
Test it: const runner = new ProcessRunner({ command: 'echo hello', watcher }); runner.start() — should print 'hello', then reprint on each file change
Step 4: Build the CLI entry point and config loader
config.ts reads .nodemonrc.json (watch patterns, delay, ignore list). index.ts parses process.argv, merges with config file, wires FileWatcher + ProcessRunner together. Adds a bin field to package.json so users can run mini-nodemon globally.
Working in src/cli/index.ts
| Input | process.argv: ['node', 'mini-nodemon', './src', 'node dist/index.js', '--delay=500'] |
| Output | FileWatcher started on ./src, ProcessRunner running 'node dist/index.js', watching for changes |
// Parse argv: `node mini-nodemon [watchDir] [command] [--delay=500]`. Read .nodemonrc.json with fs.existsSync + JSON.parse. The bin field in package.json: `{ "bin": { "mini-nodemon": "./dist/cli/index.js" } }`.
// 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 {}
}
}
// src/cli/index.ts
import { FileWatcher } from '../watcher/file-watcher'
import { ProcessRunner } from '../runner/process-runner'
import { loadConfig } from './config'
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
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.start().then(() => runner.start())
process.on('SIGINT', () => {
console.log('\n\x1b[33m[mini-nodemon]\x1b[0m Shutting down...')
runner.stop()
process.exit(0)
})
The CLI composes FileWatcher and ProcessRunner together. Config is loaded from .nodemonrc.json in the cwd, with CLI args taking precedence. SIGINT handler (Ctrl+C) gracefully shuts down both the watcher and runner process.
Watch out for:
args[1] might be undefined if user only passes watchDir — always provide a default command
The watcher must start before the runner to avoid missing events during startup
SIGINT must call runner.stop() not just process.exit() — otherwise the child process becomes orphaned
Test it: npx ts-node src/cli/index.ts ./src 'echo restarted' -- edit any file in ./src and see 'restarted' print
Step 5: Write the integration test
Test the full cycle: create a temp directory, start FileWatcher with a glob pattern, write a file to the temp dir, verify the 'change' event fires with the correct path. Test debounce fires once on rapid changes. Test ProcessRunner restart count increments.
Working in tests/integration.test.ts
| Input | Temp dir with a .ts file written to it |
| Output | FileWatcher emits 'change' event with the correct file path within 500ms |
// Use `fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-test-'))` for an isolated temp dir. Write a file with fs.writeFileSync and await the event with a Promise + setTimeout timeout.
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'
async function waitForEvent(emitter: FileWatcher, event: string, timeoutMs = 1000): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for '${event}'`)), timeoutMs)
emitter.once(event, (data: unknown) => { clearTimeout(timer); resolve(data) })
})
}
async function runTests() {
// Test 1: FileWatcher emits 'change' when a matching file is written
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-test-'))
const watcher = new FileWatcher({ patterns: ['**/*.ts'], dir: tmpDir })
await watcher.start()
await new Promise(r => setTimeout(r, 50)) // let watcher initialize
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'), `Expected path ending in test.ts, got: ${changedPath}`)
console.log('✓ FileWatcher emits change for matching files')
// Test 2: .js files are ignored when pattern is **/*.ts
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 change for .js files')
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 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) })
The integration test uses a real temp directory and actual fs.watch to verify the full file-watching pipeline works end-to-end. waitForEvent converts EventEmitter.once into a Promise with a timeout, making async assertions clean. Tests clean up after themselves with fs.rmSync.
Watch out for:
fs.watch needs a small delay after start() before it begins detecting changes — add a 50ms pause
On macOS, fs.watch may fire the filename as just the basename (not the full path) — resolve it with path.join(dir, filename)
Always clean up temp directories in tests — use try/finally or add cleanup to process.on('exit')
Test it: npx ts-node tests/integration.test.ts — should print 'All tests passed!'
The Integration Test
End-to-end integration: watch a temp directory, write a .ts file, verify the 'change' event fires with the correct path, verify debounce prevents multiple rapid restarts
import { EventEmitter } from './src/core/event-emitter'
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'
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodemon-e2e-'))
const watcher = new FileWatcher({ patterns: ['**/*.ts'], dir: tmpDir })
const events: string[] = []
watcher.on('change', (p: unknown) => events.push(String(p)))
await watcher.start()
await new Promise(r => setTimeout(r, 50))
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export const x = 1')
await new Promise(r => setTimeout(r, 300))
console.assert(events.length >= 1, 'change event fired')
console.assert(events[0].endsWith('index.ts'), 'correct path emitted')
await watcher.stop()
fs.rmSync(tmpDir, { recursive: true })
console.log('mini-nodemon e2e test passed!')
Run it with: npx ts-node tests/integration.test.ts
Skills: TypeScript · Node.js fs.watch · EventEmitter · Child Processes · CLI Tools
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
Reveal answer
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 + removeEventListener with an anonymous function never removes the listener.
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
Reveal answer
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 emission (as once() wrappers do), it mutates the array being iterated. Snapshotting with slice() before the loop prevents skipping or double-calling other listeners.
Q3: A once() listener and a regular on() listener for the same event will fire in emission order — the listener registered first fires first.
Reveal answer
true — Both on() and once() push to the same listener array. Emission iterates the array in insertion order. The type of registration (on vs once) does not affect ordering.
Q4: Which data structure is most appropriate for the listener registry in a high-performance EventEmitter?
- [ ] Plain object {} — fastest property access
- [ ] Map — O(1) lookup, handles any string key safely
- [ ] Set<[string, Listener]> — deduplicates listeners automatically
- [ ] Array of {event, fn} tuples — simplest implementation
Reveal answer
Map — O(1) lookup, handles any string key safely — Map provides O(1) amortized get/set/delete, handles any string key without prototype collision risk (unlike plain objects where 'constructor' or 'proto' could be event names), and has a clean API for checking existence.
Key Takeaways
- An EventEmitter is a Map with four methods: on, off, emit, once
- off() uses strict reference equality — anonymous functions cannot be removed
- once() requires a wrapper function and a reverse Map to allow early removal via off(originalFn)
- Snapshotting the listener array before iterating in emit() prevents mutation bugs from self-removing listeners
- Deleting empty event keys on off() prevents memory leaks in long-lived emitters
Mental model: 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 (Map) knows which phones are connected to which lines — but you must keep the exact same phone reference to unplug it.

Top comments (0)