JavaScript CustomEvent Table

PieceWhat it doesField note
new CustomEvent(type, {detail})The message with a payloaddetail is the ONLY sanctioned channel - attached properties on plain events are the classic beginner loss
target.dispatchEvent(ev)Fire itWorks on ANY EventTarget - DOM nodes, window, document, even plain objects and WebSockets
bubbles: trueClimb the treeDefault false - without it only listeners on the exact target hear the event
composed: trueCross the Shadow wallDefault false - shadow-internal events stop at the shadow boundary no matter how they bubble
addEventListener(type, fn)The receiving endSynchronous: listeners run inline before the dispatch line returns - no queue, no late delivery
e.detailRead the payloadPassed by reference through bubbling - mutating it in one listener is visible to the next
namespace namingCollision insurance'cart:added', 'player:seek' - generic names like 'update' eventually collide across modules
dispatchEvent(new Event(t))The no-payload variantPlain Event for signals that carry no data - lighter, and the shape documents the intent
Reference: the MDN CustomEvent reference. CustomEvent is the browser's built-in message bus: dispatcher and listeners stay strangers, which is the entire architectural value - remove one side and the other never breaks. The naming discipline is what keeps the bus clean: namespaced types ('cart:added') make ownership greppable, generic names ('change') make collisions a matter of time.
Bottom line: events are MOMENT notifications, not state - a listener attached after the dispatch missed it forever, and that is by design. The transport map: same page = CustomEvent, cross-tab = BroadcastChannel, cross-origin windows = postMessage, server = WebSocket. Choosing the wrong transport is the classic architecture bug - CustomEvent crosses no tabs, no origins and no time.
Related tools: the web components table (where composed and the shadow boundary are daily reality), the event listeners table (the receiving-side patterns and the signal cleanup option), the BroadcastChannel table (the cross-tab sibling), and the pointer events table (the input side of the same dispatch machinery).

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

  1. Fire a payload event: element.dispatchEvent(new CustomEvent('cart:added', { detail: { id: 7, qty: 2 }, bubbles: true })) - any EventTarget works, not just DOM nodes.
  2. 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.
  3. 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.
  4. Decouple a widget: the player module dispatches 'player:seek', the analytics module listens - neither imports the other, and removing one never breaks the other.
  5. 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.

Related tools