IntermediateBrowser application architecture~45 min

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:

JavaScriptOne listener handling clicks on any current or future list item
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.

Three related security mechanisms, and what each one actually does
MechanismWhat it does
Same-origin policyThe browser's default: a script can't read cross-origin response data
CORSA server opts in, via response headers, to let specific origins read its responses
CSPA 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

A page uses event delegation on a list's parent to handle clicks on its items. A new item is added to the list dynamically after the page loads. Does clicking it trigger the handler?
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.

A fetch to a different origin fails with a CORS error in the browser console, but the same request succeeds when run from a terminal with curl. What does this tell you?
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.

  1. 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.

  2. 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.

  1. Hint 1

    Access-Control-Allow-Origin needs to name the calling page's origin (or "*" for any origin, only valid when credentials are omitted).

  2. 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.pushState changes the URL without a page reload but triggers no navigation by itself; popstate handles 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.