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.
URL and URLSearchParams
Avoid manual query-string concatenation when the platform has a parser.
const url = new URL(
"https://example.com/products?page=2&sort=price"
);
console.log(url.pathname);
console.log(url.searchParams.get("page"));
Modify:
url.searchParams.set("page", "3");
url.searchParams.set("category", "drinks");
console.log(url.toString());
Current-page query parameters:
const params = new URLSearchParams(window.location.search);
const page = Number(params.get("page") ?? 1);
location
console.log(window.location.href);
console.log(window.location.pathname);
console.log(window.location.origin);
Navigation:
window.location.assign("/orders");
Replacing current history entry:
window.location.replace("/login");
Do not redirect to untrusted arbitrary URLs without validation. Open redirects are a real security problem.
History API
Single-page interfaces can update the browser URL without a full reload.
history.pushState(
{ filter: "open" },
"",
"?filter=open"
);
Respond to back/forward navigation:
window.addEventListener("popstate", (event) => {
console.log(event.state);
});
A real router must coordinate URL state, rendering, scroll behavior, accessibility, and server fallback behavior. Do not reinvent a full router casually.
MutationObserver
Observe DOM changes without repeatedly polling.
const observer = new MutationObserver((records) => {
for (const record of records) {
console.log(record.type);
}
});
observer.observe(document.querySelector("#orders"), {
childList: true,
subtree: true,
});
Disconnect when no longer needed:
observer.disconnect();
MutationObserver is not a substitute for proper application state management. Use it when you truly need to observe DOM changes outside your direct control.
IntersectionObserver
Detect when an element enters or exits a viewport/root intersection.
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));
Common uses:
- lazy-loading noncritical content;
- infinite-scroll sentinels;
- analytics visibility;
- activating sections.
This is generally better than running expensive scroll calculations on every scroll event.
ResizeObserver
Component size can change without viewport size changing.
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.
Web Components
Web Components are a group of platform capabilities for reusable custom UI elements.
Custom Elements
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>
Custom element names must contain a hyphen.
Shadow DOM
Shadow DOM can encapsulate internal DOM and styles.
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);
Shadow DOM changes styling and event-boundary behavior. Learn it deliberately before using it as a blanket component strategy.
Templates
<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);
Worked Example: URL-Driven Filter
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());
});
Now the browser back button participates in UI state.
Advanced Notes: Observer Choice and Custom-Element Lifecycle
Choose the observer that matches the signal:
| 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.
Custom-element lifecycle callbacks
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.
Observed attributes
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";
}
}
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
- 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.
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.
Exercises
Core
Read q and page from a URL.
Practice
Create an IntersectionObserver that logs when cards become visible.
Professional Extension
Build a custom element that renders an accessible status label and supports an observed status attribute.
Recap
Modern browser JavaScript is larger than the DOM alone. URL, history, observer, and component APIs help build applications that integrate correctly with the browser rather than fighting it.
