JavaScript CustomEvent Table
| Piece | What it does | Field note |
|---|---|---|
new CustomEvent(type, {detail}) | The message with a payload | detail is the ONLY sanctioned channel - attached properties on plain events are the classic beginner loss |
target.dispatchEvent(ev) | Fire it | Works on ANY EventTarget - DOM nodes, window, document, even plain objects and WebSockets |
bubbles: true | Climb the tree | Default false - without it only listeners on the exact target hear the event |
composed: true | Cross the Shadow wall | Default false - shadow-internal events stop at the shadow boundary no matter how they bubble |
addEventListener(type, fn) | The receiving end | Synchronous: listeners run inline before the dispatch line returns - no queue, no late delivery |
e.detail | Read the payload | Passed by reference through bubbling - mutating it in one listener is visible to the next |
namespace naming | Collision insurance | 'cart:added', 'player:seek' - generic names like 'update' eventually collide across modules |
dispatchEvent(new Event(t)) | The no-payload variant | Plain Event for signals that carry no data - lighter, and the shape documents the intent |
CustomEvent is the browser's built-in message bus: new CustomEvent('cart:added', { detail: {...}, bubbles: true }) carries named payloads between components with zero framework. The dispatcher does not know who listens and the listener does not know who dispatched - that decoupling is the entire value, and it works across plain JS, web components and third-party embeds alike.
Bottom line: the payload lives in event.detail - NOT in the event itself. Beginners attach properties to a plain Event and wonder why they vanish; CustomEvent exists precisely so detail survives the dispatch. Name events with a namespace prefix ('cart:added', 'player:seek') so unrelated modules never collide on generic names like 'update'.
The honest part: bubbling is opt-in and Shadow DOM is the wall - a custom event dispatched inside a shadow root with composed: false never escapes to the document, so listeners on body hear nothing. Same-document messaging is what CustomEvent solves; cross-tab is BroadcastChannel, cross-window is postMessage, and choosing the wrong transport is the classic architecture bug.
How to use
- Fire a payload event: element.dispatchEvent(new CustomEvent('cart:added', { detail: { id: 7, qty: 2 }, bubbles: true })) - any EventTarget works, not just DOM nodes.
- Listen for it: document.addEventListener('cart:added', e => console.log(e.detail.id)) - the handler receives the event with detail intact, and the dispatcher never knew.
- Escape a Shadow DOM: dispatch with composed: true so the event crosses the shadow boundary - without it, shadow-internal events are invisible to document-level listeners.
- Decouple a widget: the player module dispatches 'player:seek', the analytics module listens - neither imports the other, and removing one never breaks the other.
- Pick the right transport: same page = CustomEvent, cross-tab = BroadcastChannel, cross-origin iframe/window = postMessage, server = WebSocket - CustomEvent dies with the page and crosses no origins.
Frequently asked questions
Why does my event listener never fire even though dispatchEvent ran?
Three usual walls, in order: the event was dispatched on a node and you listen on document WITHOUT bubbles: true (dispatch does not bubble by default, so only listeners on the exact target hear it); the listener was attached AFTER the dispatch ran (events are not queued - no listener at dispatch time means no delivery, ever); or the event originated inside a shadow root without composed: true, which stops it at the shadow boundary. Synchronous delivery is also a gotcha: dispatchEvent runs all listeners inline before the next line of your code.
What is the difference between detail and attaching properties to the event?
detail is the ONLY sanctioned payload channel, and CustomEvent exists to carry it. Attaching arbitrary properties to an Event instance technically works within your own code but breaks the contract every listener assumes, and with native events those properties collide with the real API. detail also arrives intact through bubbling and composition - it is copied nowhere, referenced everywhere, so mutating it in one listener is visible to the next (treat detail as read-only in handlers).
Do custom events bubble, and what does composed change?
Both default to false. bubbles: true lets the event climb from the dispatch target up through ancestors so a document-level listener can catch events from deep in the tree - that is the standard pattern for lists dispatching per-item actions. composed: true is about the SHADOW boundary, not ancestry: a bubbled event still stops at the edge of its shadow root unless composed is set. For light-DOM apps bubbles alone is enough; for web components that must talk to the page, bubbles: true AND composed: true.
When is CustomEvent the wrong tool?
When the message outlives the page or crosses a boundary: cross-tab needs BroadcastChannel (CustomEvent fires only in the dispatching document), cross-origin frames need postMessage (CustomEvent crosses no origin), and state that many components READ over time needs a store, not an event - events are moment notifications, not state. Also reconsider fire-hose patterns: a thousand dispatches per second (mouse-follow) means listeners do a thousand synchronous workloads; throttle at the dispatch site or use requestAnimationFrame batching.