The Browser — Cheat Sheet
JavaScript & TypeScript · 8 topics. Download the PDF or the Instagram carousel and share it.
The DOM — Selecting and Changing the Page
The DOM is a tree of objects representing the page. You will mostly let React manage it, but understanding it is what lets you debug React and handle the cases React does not cover.
- ✓querySelector/querySelectorAll accept any CSS selector and cover nearly every selection need
- ✓querySelectorAll returns a static snapshot; getElementsBy* return live collections that update as you mutate
- ✓textContent is safe; innerHTML with untrusted input is an XSS vulnerability
- ✓dataset reads and writes data-* attributes, and values are always strings
- ✓Reading offsetHeight or getBoundingClientRect forces layout — batch reads before writes to avoid thrashing
document.querySelector('.card') // first match, or null
document.querySelectorAll('.card') // static NodeList of all matches
document.getElementById('root') // fastest, id only
// querySelectorAll returns a static list — not live
const items = document.querySelectorAll('li') // snapshot
// getElementsByClassName returns a LIVE collection that updates itself,
// which causes infinite loops if you append inside a loop over it
// NodeList has forEach but not map; convert when you need array methods
[...document.querySelectorAll('li')].map(li => li.textContent)
// Searching within an element, not the whole document
card.querySelector('.title')Events — Bubbling, Delegation and Cleanup
Events travel down to the target and back up again. Bubbling is what makes delegation possible, and delegation is what makes one listener handle a thousand rows.
- ✓Events capture down to the target then bubble back up; listeners default to the bubble phase
- ✓e.target is where the event happened, e.currentTarget is where the listener is attached
- ✓Delegation puts one listener on a container and uses closest() — it also works for elements added later
- ✓preventDefault cancels the default action; stopPropagation stops travel — they are unrelated
- ✓removeEventListener needs the identical function reference; an AbortController signal removes many at once
// document -> ... -> parent -> TARGET -> parent -> ... -> document
// capture phase | bubble phase
parent.addEventListener('click', h) // bubble (default)
parent.addEventListener('click', h, true) // capture
parent.addEventListener('click', h, { capture: true, once: true })
// e.target — where it actually happened
// e.currentTarget — where this listener is attached
parent.addEventListener('click', e => {
e.target // the button that was clicked
e.currentTarget // parent
})
e.stopPropagation() // stop travelling further
e.preventDefault() // cancel the default action; does NOT stop bubblingForms — Inputs, Validation and Submission
Forms are where most user data enters an application. The browser gives you validation, keyboard handling and accessibility free — if you use a real form element rather than a div with a button.
- ✓A real form element gives Enter-to-submit, validation, autofill and screen-reader support for free
- ✓preventDefault in the submit handler stops the page navigating away
- ✓FormData reads every named field at once — which is why inputs need name attributes, not just ids
- ✓Constraint attributes (required, minlength, pattern) provide validation without JavaScript; :user-invalid styles only after interaction
- ✓Disable the submit button while the request is in flight, or a double-click submits twice
form.addEventListener('submit', async (e) => {
e.preventDefault() // stop the page navigating
const data = new FormData(form)
data.get('email') // by name attribute
Object.fromEntries(data) // plain object, for JSON APIs
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(data)),
})
})
// Sending files: pass the FormData directly, no Content-Type header
await fetch('/api/upload', { method: 'POST', body: new FormData(form) })
// Checkboxes and multi-selects need getAll
data.getAll('topics') // ['arrays', 'graphs']Browser Storage — localStorage, Cookies and IndexedDB
Four options with different lifetimes, sizes and security properties. The choice that matters most is where an auth token goes, and the answer is usually not localStorage.
- ✓localStorage persists across restarts, sessionStorage clears with the tab, and both store strings only
- ✓localStorage access can throw in private mode or when the quota is full — always wrap it in try/catch
- ✓Anything in localStorage is readable by any script on the page, so one XSS exposes every stored token
- ✓An httpOnly, Secure, SameSite cookie cannot be read by JavaScript, which is the stronger default for auth
- ✓The storage event fires in other tabs, giving cross-tab sync for free; IndexedDB is the option for large structured data
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme') // 'dark', or null
localStorage.removeItem('theme')
// Objects must be serialised
localStorage.setItem('user', JSON.stringify(user))
const user = JSON.parse(localStorage.getItem('user') ?? 'null')
// It can throw — private mode, quota full, storage blocked
function safeSet(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)) }
catch { /* quota or blocked — carry on without persisting */ }
}
// sessionStorage: identical API, cleared when the tab closes,
// and not shared between tabs even on the same site.
// Cross-tab sync comes free with the storage event:
window.addEventListener('storage', e => {
if (e.key === 'theme') applyTheme(e.newValue) // fires in OTHER tabs
})History & URLs — Client-Side Routing Underneath
The History API changes the URL without a page load, and URLSearchParams parses query strings. Together they are what every client-side router is built on.
- ✓pushState changes the URL without a request; it does not fire popstate, so you render yourself
- ✓popstate fires when the user presses back or forward, carrying the state you pushed
- ✓Client-side routes 404 on refresh unless the server returns index.html for unknown paths
- ✓URLSearchParams handles encoding, repeated keys and ordering — never parse a query string manually
- ✓Filters and pagination belong in the URL so a view can be shared, bookmarked and survive a refresh
// Change the URL without a request
history.pushState({ page: 2 }, '', '/problems?page=2')
history.replaceState({}, '', '/problems') // no new history entry
// The user pressed back or forward
window.addEventListener('popstate', (e) => {
render(e.state) // whatever you passed to pushState
})
// pushState does NOT fire popstate — render yourself after calling it
function navigate(url, state) {
history.pushState(state, '', url)
render(state)
}
// The refresh problem: /problems/two-sum has no file on the server.
// The server must return index.html for unknown paths, or a refresh 404s.
// (Vercel and Next handle this; a bare nginx config does not.)Observers — Intersection, Resize and Mutation
Observers tell you when something changes without polling. IntersectionObserver handles lazy loading and infinite scroll far better than scroll listeners.
- ✓IntersectionObserver replaces scroll listeners for visibility — no per-frame layout reads
- ✓rootMargin lets you trigger before an element is actually visible, which makes infinite scroll feel instant
- ✓Native loading="lazy" on images needs no JavaScript at all
- ✓ResizeObserver watches element size, which the window resize event cannot; beware feedback loops
- ✓MutationObserver is a last resort for reacting to DOM changes made by code you do not control
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage()
}
},
{ rootMargin: '200px' }, // fire 200px early, so it feels instant
)
observer.observe(sentinelElement) // an empty div after the last row
observer.disconnect() // cleanup
// Lazy images need no JavaScript at all:
<img src="thumb.jpg" loading="lazy" />
// The old way, for comparison — runs on every scroll event
// and forces layout each time:
window.addEventListener('scroll', () => {
if (el.getBoundingClientRect().top < innerHeight) load()
})Useful Browser APIs
Clipboard, geolocation, notifications, media queries, crypto and Web Workers — the built-ins worth knowing before reaching for a library.
- ✓crypto.randomUUID() and navigator.clipboard.writeText() replace common dependencies with one call each
- ✓matchMedia reads media queries from JavaScript and fires on change — the correct way to react to dark mode
- ✓Permission prompts need a user gesture; requesting notifications on page load gets you permanently blocked
- ✓Always handle permission refusal — a denied prompt cannot be re-asked
- ✓Web Workers give genuine parallelism but have no DOM access, and data is copied across the boundary unless transferred
// Clipboard — requires a user gesture and a secure context
await navigator.clipboard.writeText(code)
const text = await navigator.clipboard.readText() // needs permission
// Crypto — proper random, no library needed
crypto.randomUUID() // 'a3f8...' RFC 4122 v4
crypto.getRandomValues(new Uint8Array(16))
// Media queries from JavaScript, including live changes
const dark = window.matchMedia('(prefers-color-scheme: dark)')
dark.matches // boolean now
dark.addEventListener('change', e => applyTheme(e.matches))
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
if (!reduced.matches) animate()How the Browser Renders — and Why Your Page Janks
Style, layout, paint, composite. Knowing which CSS properties skip layout is the difference between a smooth animation and a stuttering one.
- ✓The pipeline is style, layout, paint, composite — what you change decides how many stages run
- ✓transform and opacity composite on the GPU; top, left, width and height force a full layout
- ✓A frame is 16.7ms at 60fps, shared with the browser — anything over 50ms on the main thread blocks input
- ✓requestAnimationFrame syncs to paint; setInterval for animation drops frames
- ✓CLS is usually fixed by giving images explicit dimensions so space is reserved before they load
// Style -> Layout -> Paint -> Composite
// Changes LAYOUT (all four stages) — expensive
width, height, top, left, margin, padding, font-size, display
// Changes PAINT only (skips layout)
color, background-color, box-shadow, border-radius, visibility
// COMPOSITE only (GPU, skips layout and paint) — cheapest
transform, opacity
// So this janks:
el.style.left = x + 'px'
// And this does not:
el.style.transform = `translateX(${x}px)`
// Hint to the browser before an animation starts:
.card { will-change: transform } // use sparingly; it costs memory