076: Browser APIs Beyond the DOM: URL, History, Observers, and Web Components
Outcomes
By the end of this lesson, you can:
- read and construct URLs safely;
- use
URLSearchParams; - understand
locationand the History API; - observe DOM mutations and element visibility;
- explain when observers are better than polling;
- understand the purpose of Web Components and Custom Elements;
- identify browser APIs as host APIs rather than core ECMAScript.
The DOM is only one part of the browser programming model. The browser also owns the current URL, the session history, notifications about changes to the page, and a set of primitives for defining reusable elements. These are host APIs: JavaScript can call them in a browser, but they are not part of the core ECMAScript language itself.
URL and URLSearchParams
Query strings look simple until values contain spaces, &, ?, or other characters with special meaning in a URL. Manual concatenation makes it easy to produce an invalid URL or accidentally change the meaning of a value. When the platform already provides a parser and serializer, use those instead.
const url = new URL(
"https://example.com/products?page=2&sort=price"
);
console.log(url.pathname);
console.log(url.searchParams.get("page"));
URL parses the address into named parts. In this example, pathname is /products, and searchParams.get("page") reads the page query parameter as a string. That distinction matters: the URL API parses the URL, but it does not decide what a value means to your application or automatically convert it to a number.
Modify the query parameters through URLSearchParams rather than editing the query string by hand:
url.searchParams.set("page", "3");
url.searchParams.set("category", "drinks");
console.log(url.toString());
set() replaces an existing value or creates the parameter if it is not present. Calling toString() on the URL serializes the updated URL, including the appropriate encoding. This is the safer boundary for values that come from a user, a form, or another external source.
To read the query string for the page currently open in the browser, construct URLSearchParams from window.location.search:
const params = new URLSearchParams(window.location.search);
const page = Number(params.get("page") ?? 1);
get() returns null when the parameter is absent, so the nullish coalescing operator supplies a default before conversion. The conversion to a number is application logic; inspect or validate the result if invalid values would cause a problem for pagination or another feature.
location
The location object describes the document's current address and provides navigation operations. These values are often useful when debugging a page that appears to be rendering the wrong route or query state:
console.log(window.location.href);
console.log(window.location.pathname);
console.log(window.location.origin);
href is the complete serialized URL. pathname identifies the path, while origin identifies the scheme, host, and port that form the URL's origin. Looking at these separate fields is more useful than treating the entire URL as an opaque string.
Navigation with assign() loads a new location and keeps the current entry available in the session history:
window.location.assign("/orders");
Use replace() when the current entry should be replaced instead:
window.location.replace("/login");
That distinction is useful for flows such as redirecting an unauthenticated visitor to a login page, where returning to the intermediate location with the Back button may not make sense. Do not redirect to untrusted arbitrary URLs without validation. Open redirects are a real security problem because an apparently trusted site can be used to send someone to a malicious destination.
History API
Single-page interfaces sometimes need to change the address bar while keeping the current document loaded. The History API supports that behavior. pushState() adds a history entry without performing a full page reload:
history.pushState(
{ filter: "open" },
"",
"?filter=open"
);
The first argument is application state associated with the new entry, and the URL becomes part of the browser's navigable history. Updating the URL is not the same thing as rendering the corresponding UI; your application still has to coordinate those two pieces.
When the user moves backward or forward through entries, the browser emits popstate:
window.addEventListener("popstate", (event) => {
console.log(event.state);
});
This event is where a small interface can read the state and render the appropriate view. A real router has more responsibilities: it must coordinate URL state, rendering, scroll behavior, accessibility, and server fallback behavior. Do not reinvent a full router casually. A few calls to pushState() can be appropriate for a focused interaction, but routing becomes a cross-cutting concern quickly.
MutationObserver
Code that needs to react to DOM changes outside its direct control often starts with polling: check repeatedly, wait, and check again. That wastes work when nothing has changed and introduces timing questions. MutationObserver lets the browser notify you about mutations instead.
const observer = new MutationObserver((records) => {
for (const record of records) {
console.log(record.type);
}
});
observer.observe(document.querySelector("#orders"), {
childList: true,
subtree: true,
});
The callback receives mutation records describing the changes. Here, childList: true watches additions and removals of child nodes, and subtree: true extends that watch to descendants of #orders. The observer reports what happened in the DOM; it does not tell you that the DOM is the source of truth for your application.
Disconnect an observer as soon as the work that required it is finished:
observer.disconnect();
Leaving observers connected forever can retain work and produce callbacks after a component or feature is no longer relevant. MutationObserver is not a substitute for proper application state management. Use it when you truly need to observe DOM changes outside your direct control, not to compensate for unclear ownership of state.
IntersectionObserver
Scroll handlers often perform expensive calculations on every scroll event, even though the application only needs to know whether an element has entered or left a viewport or another root. IntersectionObserver reports those intersection changes directly.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log("visible", entry.target);
}
}
});
document
.querySelectorAll("[data-lazy-section]")
.forEach((element) => observer.observe(element));
The callback receives entries for the observed elements. In this example, only entries currently intersecting the viewport are logged. The same pattern can be extended to stop observing an element after it has been handled, depending on the feature's requirements.
Common uses include:
- lazy-loading noncritical content;
- infinite-scroll sentinels;
- analytics visibility;
- activating sections.
This is generally better than running expensive scroll calculations on every scroll event because the browser can schedule intersection notifications around its knowledge of layout and visibility. It does not remove the need to clean up observers or to keep the callback itself reasonably lightweight.
ResizeObserver
An element's size can change even when the viewport does not. A panel might change because its content changes, a sibling appears, or a responsive layout selects a different arrangement. ResizeObserver reports changes to an element's box size:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log(entry.contentRect.width);
}
});
observer.observe(document.querySelector(".panel"));
Use CSS container queries for pure styling decisions. Use ResizeObserver when JavaScript behavior genuinely depends on element dimensions, such as choosing a rendering strategy or recalculating a canvas layout. If CSS can express the response, keeping it in CSS avoids unnecessary JavaScript coordination.
Web Components
Web Components are a group of platform capabilities for reusable custom UI elements. The group includes Custom Elements, Shadow DOM, and templates. These primitives can be used independently or together; adopting one does not require building an entire framework-like component system.
Custom Elements
Custom Elements let you associate a JavaScript class with an HTML element name. The connectedCallback() lifecycle method runs when an instance is connected to the document:
class UserBadge extends HTMLElement {
connectedCallback() {
const name = this.getAttribute("name") ?? "Guest";
this.textContent = `User: ${name}`;
}
}
customElements.define("user-badge", UserBadge);
HTML:
<user-badge name="Maya"></user-badge>
The element reads its name attribute when it connects and renders a fallback when the attribute is missing. Custom element names must contain a hyphen. That naming rule keeps them distinct from current and future built-in HTML element names.
Shadow DOM
Sometimes a reusable element needs internal markup and styles that should not collide with the document around it. Shadow DOM can encapsulate that internal DOM and styling:
class StatusBadge extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host {
display: inline-block;
}
</style>
<span part="label"></span>
`;
}
connectedCallback() {
this.shadowRoot.querySelector("[part='label']").textContent =
this.getAttribute("status") ?? "Unknown";
}
}
customElements.define("status-badge", StatusBadge);
attachShadow({ mode: "open" }) creates the shadow root, and :host styles the custom element from inside that root. The part attribute provides a named styling hook for consumers that need to style an exposed internal part.
Shadow DOM changes styling and event-boundary behavior. It is not merely a private wrapper around ordinary markup, and it does not automatically make a component accessible. Learn those boundary rules deliberately before using Shadow DOM as a blanket component strategy.
Templates
The template element stores markup that is inert until it is cloned. This is useful when a repeated structure needs to be defined once and instantiated later:
<template id="product-card-template">
<article class="product-card">
<h2></h2>
</article>
</template>
const template = document.querySelector(
"#product-card-template"
);
const clone = template.content.cloneNode(true);
clone.querySelector("h2").textContent = "Tea";
document.body.append(clone);
template.content is a document fragment. Cloning with true copies the nested structure, after which the example fills in the heading and appends the clone to the document. The template itself is not rendered as part of the page until content is explicitly cloned and inserted.
Worked Example: URL-Driven Filter
A URL-driven filter is a useful place to combine parsing, serialization, and history. The application can treat the URL as a representation of filter state and reconstruct that state when the user navigates through history.
function readFilters() {
const params = new URLSearchParams(location.search);
return {
query: params.get("q") ?? "",
page: Number(params.get("page") ?? 1),
};
}
function writeFilters(filters) {
const url = new URL(location.href);
url.searchParams.set("q", filters.query);
url.searchParams.set("page", String(filters.page));
history.pushState(filters, "", url);
}
window.addEventListener("popstate", () => {
render(readFilters());
});
readFilters() converts the current query string into the shape the UI uses. writeFilters() starts from the current URL, changes only the relevant parameters, and adds a history entry. The popstate handler then reads the URL again and renders the resulting filters. The example assumes that render() exists elsewhere; the browser back button participates in UI state only because the application connects history changes to rendering.
Advanced Notes: Observer Choice and Custom-Element Lifecycle
Choose the observer that matches the signal you actually need:
| Need | Prefer |
|---|---|
| DOM tree/attribute changes | MutationObserver |
| visibility/intersection | IntersectionObserver |
| element box-size changes | ResizeObserver |
| viewport media conditions | CSS media queries / matchMedia |
| component style response to size | CSS container queries where possible |
Polling with setInterval() is usually the wrong first tool for these problems. It asks the application to keep checking instead of allowing the browser to report a relevant change. There are still cases for scheduled work, but polling should be a deliberate choice rather than the default response to an event-driven problem.
Custom-element lifecycle callbacks
Custom elements often own resources such as timers, subscriptions, or observers. Pair setup in connectedCallback() with cleanup in disconnectedCallback():
class LiveClock extends HTMLElement {
#timerId;
connectedCallback() {
this.#timerId = setInterval(() => {
this.textContent = new Date().toLocaleTimeString();
}, 1000);
}
disconnectedCallback() {
clearInterval(this.#timerId);
}
}
customElements.define("live-clock", LiveClock);
The lifecycle makes cleanup explicit. Without it, the interval can continue running after the element leaves the document, doing work for UI that is no longer visible. In a more involved element, the same boundary is where you would disconnect observers and remove event listeners that the element registered.
Observed attributes
When an attribute is part of a custom element's public input, observedAttributes can make changes to that input visible to the element:
class StatusBadge extends HTMLElement {
static observedAttributes = ["status"];
attributeChangedCallback(name, oldValue, newValue) {
if (name === "status" && oldValue !== newValue) {
this.render();
}
}
render() {
this.textContent = this.getAttribute("status") ?? "Unknown";
}
}
The callback receives the attribute name and its old and new values. The comparison avoids rendering for a notification that does not represent a value change. Do not build a framework inside a custom element unless the complexity justifies it. Native components are most useful when their lifecycle, encapsulation, and interoperability solve a concrete problem.
Mistakes and Debugging
When these APIs misbehave, first identify which browser-owned boundary is involved: URL parsing, navigation, history state, DOM mutation, visibility, sizing, or component lifecycle. Common mistakes include:
- concatenating query strings manually;
- not encoding user-controlled URL values;
- using History API without handling back/forward navigation;
- leaving observers connected forever;
- using MutationObserver to compensate for unclear state ownership;
- using scroll events for work IntersectionObserver already solves;
- building a custom element that is inaccessible without keyboard/name/state support.
For URL problems, inspect href, pathname, origin, and the parsed search parameters rather than debugging a concatenated string. For history problems, check both the URL and the state passed to pushState(), then exercise the Back and Forward buttons to confirm that popstate causes the UI to render again. For observer problems, verify the observed target, the observer options, and whether cleanup runs when the feature or element is removed. For custom elements, inspect connection and disconnection, attribute changes, and the resulting accessible name and state.
Best Practices
- Prefer platform parsers for URLs.
- Validate navigation destinations.
- Disconnect observers.
- Use observers for observation, not as a replacement for application architecture.
- Prefer CSS for styling behavior and JS observers for behavioral needs.
- Treat Web Components as a real component model with lifecycle and accessibility responsibilities.
These practices all reduce hidden coordination. Let the URL APIs handle URL syntax, let browser observers report the signal they are designed to report, and keep component cleanup close to component setup. The browser provides the primitives, but the application remains responsible for validation, rendering, state ownership, and accessibility.
Exercises
Core
Read q and page from a URL. Use URLSearchParams, apply sensible defaults for missing values, and remember that query parameters are read as strings before any numeric conversion.
Practice
Create an IntersectionObserver that logs when cards become visible. Observe a set of cards, log the target when its entry is intersecting, and consider when the observer should be disconnected.
Professional Extension
Build a custom element that renders an accessible status label and supports an observed status attribute. Give it a sensible fallback when the attribute is absent, update the rendered label when the attribute changes, and ensure that the element's name and state are available to keyboard and assistive-technology users.
Recap
Modern browser JavaScript is larger than the DOM alone. URL and URLSearchParams APIs provide structured URL parsing and serialization; location and the History API connect navigation to application state; observers report DOM, visibility, and size changes without defaulting to polling; and Web Components provide native primitives for custom elements, encapsulation, and templates. Used with explicit cleanup, validation, rendering, and accessibility decisions, these APIs help applications integrate correctly with the browser rather than fighting it.
