Browser Application Architecture
Event delegation and custom events for scalable UI wiring, client-side navigation with the History API, web components in moderation, and the browser's core security model.
By the end of this lesson, you can
- Use event delegation to handle events for many elements — including ones added later — with one listener
- Dispatch and listen for a custom event to decouple two pieces of UI code
- Implement client-side navigation with the History API without reimplementing a framework
- Explain the same-origin policy, what CORS relaxes, and what CSP restricts
Why it matters
The Browser and the DOM attached one listener per
element and mentioned that events bubble. At application scale, that bubbling behavior becomes a
deliberate architectural tool, fetch-and-render apps need client-side navigation without a full
page reload, and understanding the browser’s actual security boundaries becomes necessary rather
than optional.
Event delegation
Attaching a listener to every item in a list — especially one whose items are added or removed dynamically — means constantly adding and removing listeners. Event delegation attaches one listener to a stable ancestor and uses bubbling (from The Browser and the DOM) to handle events from any current or future descendant:
const list = document.querySelector('#task-list');
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return; // click didn't originate on (or inside) a relevant button
const action = button.dataset.action;
const taskId = button.closest('[data-task-id]')?.dataset.taskId;
if (action === 'delete') deleteTask(taskId);
if (action === 'complete') completeTask(taskId);
});
// A task added later is automatically handled — no new listener needed:
list.insertAdjacentHTML('beforeend', taskItemHTML(newTask));event.target.closest(selector) finds the nearest matching ancestor (or the element itself) —
essential here, since event.target could be a child of the actual button (an icon inside it, say),
not the button itself.
Custom events
A custom event lets one piece of UI code announce something happened without directly calling into whatever else might care — decoupling the two:
const cart = document.querySelector('#cart');
// Dispatched by whatever adds an item, with no knowledge of who's listening
cart.dispatchEvent(
new CustomEvent('cart:item-added', {
detail: { itemId: 42, quantity: 1 },
bubbles: true,
}),
);
// Listened for by a completely separate piece of code — a total count badge, say
document.addEventListener('cart:item-added', (event) => {
console.log('Item added:', event.detail.itemId);
});
This is the same pattern browser-native events (click, submit) already follow — CustomEvent
just lets application code participate in it directly, with event.detail carrying arbitrary data.
Client-side navigation with the History API
history.pushState changes the URL shown in the address bar without a full page reload;
popstate fires when the user navigates with the browser’s back/forward buttons:
function navigateTo(path) {
history.pushState({}, '', path);
renderPageFor(path); // application code decides what to render for this path
}
window.addEventListener('popstate', () => {
renderPageFor(location.pathname); // back/forward button pressed
});
document.querySelector('#nav-link').addEventListener('click', (event) => {
event.preventDefault();
navigateTo('/settings');
});
Web components, briefly
A web component — a custom element (class MyWidget extends HTMLElement), registered with
customElements.define() — packages markup, styling, and behavior into a reusable, framework-
agnostic tag. They’re a real, standard option, but not a default reach: a framework’s component
model is usually more productive for an application’s own internal UI, and hand-rolled DOM
manipulation remains simplest for small, one-off interactive elements. Web components earn their
place specifically for a reusable widget meant to work across different frameworks or no
framework at all — a design-system component shipped as a library, for instance.
Progressive enhancement and observer APIs
Progressive enhancement means the core functionality works with plain HTML, and JavaScript adds
capability rather than being required for the page to function at all — the same principle behind
this site’s own “core content works without client-side JavaScript” requirement.
IntersectionObserver is a common enhancement example — lazy-loading an image only once it’s about
to scroll into view, without blocking the initial page render on every image loading eagerly:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src; // load the real image now
observer.unobserve(entry.target);
}
}
});
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
Security foundations
The same-origin policy restricts what a script running on one origin (scheme + host + port) can
directly read from another — https://example.com cannot read a response from
https://api.other.com by default, regardless of whether the request itself succeeds.
| Mechanism | What it does |
|---|---|
| Same-origin policy | The browser's default: a script can't read cross-origin response data |
| CORS | A server opts in, via response headers, to let specific origins read its responses |
| CSP | A server declares which sources of scripts/styles/etc. a page is allowed to load, mitigating the impact of an XSS injection even if one occurs |
CSP (Content-Security-Policy) is a defense-in-depth layer specifically relevant to the XSS risk from
The Browser and the DOM — even if untrusted text somehow
gets injected as a <script> tag, a strict CSP can still block it from executing, by restricting
which sources scripts are allowed to load from.
Check your understanding
Or reveal the answer without checking
Answer:Yes — the delegated listener on the parent catches the bubbled click from any current or future descendant
Delegation relies on event bubbling to a stable ancestor, not on which specific elements existed when the listener was attached — a newly added descendant's click bubbles up and is caught exactly the same way as an original one's.
Or reveal the answer without checking
Answer:The server's response didn't include headers granting the calling page's origin permission to read it — the browser blocked the script from reading a response that did arrive
CORS is enforced by the browser, not the server or the network — curl has no same-origin policy to enforce, so it can read any response. The request itself likely succeeded in both cases; the browser specifically withheld the response from the page's script because the server didn't grant that origin permission via CORS headers.
Practice: warm-up
A page has <ul id="items"><li data-id="1">A</li><li data-id="2">B</li></ul>. Write one delegated
click listener on #items that logs the clicked item’s data-id, and verify (by reasoning, not
just running it) that it still works after a new <li data-id="3">C</li> is added later.
Stuck? Reveal one hint at a time.
Hint 1
event.target inside the listener is the specific element that was actually clicked — likely the li itself here, but closest() is safer if the li could contain nested markup later.
Hint 2
The listener is attached once, to #items, not to any individual li — so it needs no changes when new li elements are added.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
document.querySelector('#items').addEventListener('click', (event) => {
const item = event.target.closest('li[data-id]');
if (!item) return;
console.log(item.dataset.id);
});Because the listener lives on #items, not on any specific <li>, a <li> added afterward is
handled automatically — its click still bubbles up to the same listener.
Practice: apply it
Sketch (in comments or pseudocode is fine) the CORS headers a server at https://api.example.com
would need to send to let a page at https://app.example.com successfully fetch its data with
credentials: 'omit' (no cookies).
Stuck? Reveal one hint at a time.
Hint 1
Access-Control-Allow-Origin needs to name the calling page's origin (or "*" for any origin, only valid when credentials are omitted).
Hint 2
Without credentials involved, a single header naming the allowed origin is normally sufficient for a simple GET request.
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
Access-Control-Allow-Origin: https://app.example.com(or Access-Control-Allow-Origin: *, valid here specifically because no credentials are involved —
a wildcard is not permitted once credentials: 'include' is used, which instead requires naming the
exact origin and setting Access-Control-Allow-Credentials: true.)
Summary
- Event delegation attaches one listener to a stable ancestor, relying on bubbling to handle events from any current or future descendant — no per-element wiring, and dynamically added elements work automatically.
- A custom event (
CustomEvent,dispatchEvent) decouples the code that announces something from whatever else reacts to it, following the same pattern as native browser events. history.pushStatechanges the URL without a page reload but triggers no navigation by itself;popstatehandles the back/forward buttons — the mechanism a client-side router is built on.- Web components are a real, standard tool, best reserved for a widget meant to work across different frameworks — not a default reach for an application’s own internal UI.
- The same-origin policy is the browser’s default restriction on reading cross-origin responses; CORS is how a server relaxes it for specific origins; CSP restricts what a page is allowed to load, mitigating the impact of an XSS injection even if one occurs.