Abortable Event Hub and Asynchronous Control
Practical 04 - Abortable Event Hub and Asynchronous Control
Related: Chapter 4 · Lecture slides
Objective
Build a framework-agnostic, publish-subscribe event hub in modern JavaScript that manages decoupled communication across application modules.
You will implement private state encapsulation using closures, support single-operation listener teardown using AbortSignal, isolate listener errors so that a failure in one subscriber cannot prevent others from executing, and eliminate race conditions in asynchronous requests.
Prerequisite Notice: This laboratory is implemented entirely in pure modern JavaScript (ES Modules). To maintain dependency order, compile-time TypeScript contracts for event names and payload shapes will be introduced as an optional extension in Chapter 5.
Prerequisites and setup
You need a modern browser with Developer Tools or a current Node.js runtime supporting ES Modules (Node 18+).
Create your exercise workspace:
Do not import third-party event libraries (such as EventEmitter or RxJS). All subscription mechanics, signal handling, and scheduling must be constructed from platform primitives.
Stage 1 - Core publish-subscribe engine with closure encapsulation
In event-hub.js, implement a factory function createEventHub() that uses closures to maintain private subscriber state:
- Maintain an internal
Map<string, Set<Function>>mapping event names to sets of subscriber callbacks. Do not expose this map directly on the returned object. - Implement the core subscription and dispatch methods:
on(event, listener): Adds a listener to the set. Returns a parameterlessunsubscribe()function that removes the listener.off(event, listener): Explicitly removes a listener from the specified event.emit(event, payload): Iterates over a copy of the active subscriber set and invokes each listener withpayload.
- Ensure that calling
unsubscribe()multiple times or callingoff()with an unregistered listener fails gracefully without throwing.
Verify: Write a test script in app.js subscribing three distinct listeners to a service:selected event. Emit the event with an ID payload, verify all three receive the payload, unsubscribe the second listener, emit again, and confirm only the remaining two listeners execute.
Stage 2 - Cooperative cancellation and teardown with AbortSignal
Extend on() to support standard web platform cancellation options:
- The
onceOption: Ifonce: trueis passed, wrap the listener so it automatically unregisters itself immediately upon its first execution before invoking the user callback. - The
signalOption: If anAbortSignalis supplied:- If the signal is already aborted (
signal.aborted === true), return immediately without registering the listener. - Otherwise, attach an
abortevent listener to the signal that automatically cleans up and removes the subscription whensignal.abort()is triggered. - Ensure that if the subscription is manually removed via
unsubscribe()oronce, the internalabortlistener on the signal is also removed to prevent memory leaks.
- If the signal is already aborted (
Verify: Create an AbortController. Register three different subscriptions (e.g. for window resize, modal status, and data sync) passing { signal: controller.signal }. Call controller.abort(). Emit events on all channels and confirm that none of the aborted listeners execute.
Stage 3 - Listener error isolation and microtask dispatch
In standard synchronous event dispatching, if listener 1 throws an unhandled error, execution halts immediately, and listeners 2 and 3 never receive the event.
Harden emit() against subscriber exceptions:
- Wrap individual listener invocations in a
try...catchboundary: - Implement an asynchronous variant
emitAsync(event, payload)that dispatches subscriber notifications as microtasks (queueMicrotaskorPromise.resolve().then()) to prevent long listener tasks from blocking the caller.
Verify: Register three listeners for data:mutation. Configure the second listener to deliberately throw new Error("Database write failed"). Emit the event. Verify that Listener 1 and Listener 3 execute successfully and that the error from Listener 2 is logged to the diagnostic console.
Stage 4 - Live search integration and race-condition elimination
In search-service.js and app.js, build a live citizen service search component using your event hub:
- Maintain an active
AbortControllerin module scope. - When the user types into an input field:
- Debounce input events by 300ms using a closure-based debounce utility.
- If a previous network request is active, invoke
activeController.abort(). - Create a new
AbortControllerand pass itssignaltofetch('/api/services?q=...'). - When the request starts, emit
search:starton the hub. - If the fetch resolves with valid data, emit
search:successwith the results. - If the fetch throws
AbortError, emitsearch:abortedand do not touch the UI. - If the fetch throws any other error, emit
search:error.
- In
app.js, subscribe UI renderers tosearch:successand status banners tosearch:start/search:error.
Verify: Simulate a slow request (800ms) for "res" followed immediately by a fast request (150ms) for "residence". Verify that:
- The
"res"request is aborted cleanly viaAbortController. - The UI never displays stale
"res"results. - The event hub logs the cancellation as expected control flow without throwing unhandled exceptions.
What to submit
Submit your implementation files (event-hub.js, search-service.js, app.js) and a completed verification report:
| Target | Requirement | Observed Evidence | Explanation | Confounders or Limits |
|---|---|---|---|---|
| Closure Privacy | Subscriber map not directly accessible | Inspection of hub object | Private map encapsulated within factory closure | Tested via Object.keys() |
AbortSignal Teardown | All listeners removed on controller.abort() | Post-abort emit test | Event listener count drops to 0; no notifications fired | Verified with active signal |
| Error Isolation | Thrown error in one listener does not halt others | Fault injection test | Listeners 1 & 3 complete despite Listener 2 throwing | Logged via reportError |
| Race Prevention | Stale async requests do not overwrite UI | Out-of-order latency test | AbortError caught; only newest search updates DOM | Simulated network delays |
When an experiment gives an unexpected result
- Aborted listener still runs: Ensure
signal.addEventListener('abort', ...)is correctly bound and that the cleanup function removes the exact reference from theSet. - Memory leak warning with signals: If an event hub subscription is removed manually before
signal.abort()is called, ensure you also callsignal.removeEventListener('abort', cleanup)to release the signal reference. fetch()does not cancel: Confirm that you are passing{ signal: controller.signal }in the options object offetch(url, options), and that your mock server or test environment supportsAbortSignal.- Listeners execute in unexpected order: In JavaScript,
Setpreserves insertion order. Modifying or re-registering listeners can alter invocation sequence.