History API
The History API enables JavaScript to interact with the browser's session history — the stack of pages the user has visited in the current tab. It allows developers to programmatically navigate, modify the history stack, and respond to navigation events, forming the backbone of Single Page Application (SPA) routing.
Before the History API, SPAs relied on the URL hash (#/route) to manage navigation without page reloads. pushState and replaceState provide clean URLs without hash fragments, while popstate and hashchange events let applications react to navigation initiated by the browser's back/forward buttons.
The session history is a stack of entries, each representing a document the user has visited in the current tab. When the user navigates to a new page, the entry is pushed onto the stack. The back button pops the current entry and loads the previous one. pushState() adds entries to the stack without causing a page load, enabling JavaScript-driven navigation that feels like a traditional multi-page experience.
| Concept | Description |
|---|---|
| Session history | The ordered list of documents visited in the current tab |
| Current entry | The top entry on the stack — what the user is viewing now |
| History.length | Number of entries in the session history stack |
| History.scrollRestoration | Controls whether scroll position is restored on navigation |
| State object | Serializable data associated with a history entry |
best practice
history.pushState(state, title, url) adds a new entry to the session history stack. It accepts three arguments: a state object (any serializable value associated with the entry), a title string (currently ignored by most browsers but should not be empty), and an optional URL that becomes the current URL displayed in the address bar. The page does not reload — only the URL changes.
| 1 | <!-- pushState: programmatic navigation without reload --> |
| 2 | <!DOCTYPE html> |
| 3 | <html lang="en"> |
| 4 | <head> |
| 5 | <meta charset="UTF-8" /> |
| 6 | <title>SPA with History API</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <nav> |
| 10 | <a href="#" data-route="/">Home</a> |
| 11 | <a href="#" data-route="/about">About</a> |
| 12 | <a href="#" data-route="/contact">Contact</a> |
| 13 | </nav> |
| 14 | <main id="content"></main> |
| 15 | |
| 16 | <script> |
| 17 | // Navigation handler using pushState |
| 18 | document.querySelectorAll('[data-route]').forEach(link => { |
| 19 | link.addEventListener('click', (e) => { |
| 20 | e.preventDefault(); |
| 21 | const url = link.dataset.route; |
| 22 | const title = url === '/' ? 'Home' : url.slice(1); |
| 23 | |
| 24 | // Push a new history entry |
| 25 | history.pushState( |
| 26 | { page: url, title }, |
| 27 | title, |
| 28 | url |
| 29 | ); |
| 30 | |
| 31 | // Update the page content |
| 32 | renderRoute(url); |
| 33 | }); |
| 34 | }); |
| 35 | |
| 36 | function renderRoute(url) { |
| 37 | const content = document.getElementById('content'); |
| 38 | switch (url) { |
| 39 | case '/': |
| 40 | content.innerHTML = '<h1>Home Page</h1><p>Welcome to the SPA.</p>'; |
| 41 | break; |
| 42 | case '/about': |
| 43 | content.innerHTML = '<h1>About Us</h1><p>We build web apps.</p>'; |
| 44 | break; |
| 45 | case '/contact': |
| 46 | content.innerHTML = '<h1>Contact</h1><p>Email us at hello@example.com</p>'; |
| 47 | break; |
| 48 | default: |
| 49 | content.innerHTML = '<h1>404</h1><p>Page not found.</p>'; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // Initial render |
| 54 | renderRoute('/'); |
| 55 | </script> |
| 56 | </body> |
| 57 | </html> |
| 58 | |
| 59 | // pushState signatures: |
| 60 | // history.pushState(state, title) |
| 61 | // history.pushState(state, title, url) |
| 62 | // |
| 63 | // state: any structured-cloneable value (null to omit) |
| 64 | // title: string (ignored by Firefox, required by spec) |
| 65 | // url: optional relative or absolute URL (same-origin only) |
| 66 | // |
| 67 | // Important: url must be same-origin as the current page. |
| 68 | // An attempt to push cross-origin URL throws a SecurityError. |
info
history.replaceState(state, title, url) modifies the current history entry instead of pushing a new one. It is useful for updating the URL or state data without adding a new entry — for example, updating search parameters, tracking UI state, or correcting a URL without cluttering the history stack.
| 1 | <!-- replaceState: modify the current entry --> |
| 2 | <script> |
| 3 | // Update URL query params without adding history entry |
| 4 | function updateSearchParams(params) { |
| 5 | const url = new URL(window.location); |
| 6 | Object.entries(params).forEach(([key, value]) => { |
| 7 | url.searchParams.set(key, value); |
| 8 | }); |
| 9 | history.replaceState( |
| 10 | { ...history.state, searchParams: params }, |
| 11 | '', |
| 12 | url.toString() |
| 13 | ); |
| 14 | } |
| 15 | |
| 16 | // Track active tab state without extra history entries |
| 17 | function switchTab(tabName) { |
| 18 | // Replace current state with updated tab info |
| 19 | history.replaceState( |
| 20 | { tab: tabName }, |
| 21 | '', |
| 22 | `?tab=${tabName}` |
| 23 | ); |
| 24 | renderTab(tabName); |
| 25 | } |
| 26 | |
| 27 | // Update filters without navigating away |
| 28 | function applyFilters(filters) { |
| 29 | const url = new URL(window.location); |
| 30 | url.searchParams.set('filters', JSON.stringify(filters)); |
| 31 | history.replaceState( |
| 32 | { ...history.state, filters }, |
| 33 | document.title, |
| 34 | url.toString() |
| 35 | ); |
| 36 | } |
| 37 | |
| 38 | // Restore state on page load |
| 39 | window.addEventListener('DOMContentLoaded', () => { |
| 40 | if (history.state) { |
| 41 | if (history.state.tab) switchTab(history.state.tab); |
| 42 | if (history.state.filters) applyFilters(history.state.filters); |
| 43 | } |
| 44 | }); |
| 45 | |
| 46 | // Use case: tracking modal state (avoids back-button trap) |
| 47 | function openModal(modalId) { |
| 48 | history.replaceState( |
| 49 | { ...history.state, modal: modalId }, |
| 50 | '', |
| 51 | `?modal=${modalId}` |
| 52 | ); |
| 53 | showModal(modalId); |
| 54 | } |
| 55 | |
| 56 | function closeModal() { |
| 57 | history.replaceState( |
| 58 | { ...history.state, modal: null }, |
| 59 | '', |
| 60 | window.location.pathname |
| 61 | ); |
| 62 | hideModal(); |
| 63 | } |
| 64 | </script> |
| 65 | |
| 66 | // Key difference: |
| 67 | // pushState() → adds entry, user can press Back to undo |
| 68 | // replaceState() → replaces current entry, Back goes further back |
Live preview showing pushState and replaceState in action — click navigation links to see the history stack change:
The popstate event fires on the window object whenever the active history entry changes — specifically when the user clicks the browser's back or forward button, or when history.back(), history.forward(), or history.go() are called. It does NOT fire when pushState() or replaceState() are called. The event provides the state object of the newly active history entry.
| 1 | <!-- popstate: handling back/forward navigation --> |
| 2 | <script> |
| 3 | // Listen for history navigation |
| 4 | window.addEventListener('popstate', (event) => { |
| 5 | console.log('Navigation occurred:', { |
| 6 | state: event.state, |
| 7 | url: document.location.href, |
| 8 | }); |
| 9 | |
| 10 | // Restore the page based on state |
| 11 | if (event.state && event.state.page) { |
| 12 | renderRoute(event.state.page); |
| 13 | } else { |
| 14 | // Fallback for initial entry (no state) |
| 15 | renderRoute('/'); |
| 16 | } |
| 17 | }); |
| 18 | |
| 19 | // Comprehensive SPA router with back/forward support |
| 20 | const routes = { |
| 21 | '/': { title: 'Home', render: () => '<h1>Home</h1>' }, |
| 22 | '/about': { title: 'About', render: () => '<h1>About</h1>' }, |
| 23 | '/products': { title: 'Products',render: () => '<h1>Products</h1>' }, |
| 24 | '/contact': { title: 'Contact', render: () => '<h1>Contact</h1>' }, |
| 25 | }; |
| 26 | |
| 27 | function navigate(url) { |
| 28 | const route = routes[url]; |
| 29 | if (!route) return; |
| 30 | |
| 31 | // Update document title |
| 32 | document.title = route.title; |
| 33 | |
| 34 | // Push state and render |
| 35 | history.pushState({ url }, route.title, url); |
| 36 | document.getElementById('app').innerHTML = route.render(); |
| 37 | } |
| 38 | |
| 39 | function renderRoute(url) { |
| 40 | const route = routes[url] || routes['/']; |
| 41 | document.title = route.title; |
| 42 | document.getElementById('app').innerHTML = route.render(); |
| 43 | } |
| 44 | |
| 45 | // Handle back/forward |
| 46 | window.addEventListener('popstate', (event) => { |
| 47 | const url = event.state?.url || '/'; |
| 48 | renderRoute(url); |
| 49 | }); |
| 50 | |
| 51 | // Intercept link clicks for SPA navigation |
| 52 | document.addEventListener('click', (e) => { |
| 53 | const link = e.target.closest('a[data-spa]'); |
| 54 | if (link) { |
| 55 | e.preventDefault(); |
| 56 | navigate(link.getAttribute('href')); |
| 57 | } |
| 58 | }); |
| 59 | |
| 60 | // Initialize on page load |
| 61 | window.addEventListener('DOMContentLoaded', () => { |
| 62 | // If we landed on a URL directly, load that route |
| 63 | const path = window.location.pathname; |
| 64 | if (path !== '/') { |
| 65 | renderRoute(path); |
| 66 | } |
| 67 | }); |
| 68 | </script> |
warning
The hashchange event fires when the URL fragment identifier (the part after #) changes. This occurs when the user clicks an anchor link (<a href="#section">), when JavaScript sets location.hash, or when the browser navigates through history entries that differ only in the hash. Unlike popstate, hashchange is simpler and has broader browser support, making it suitable for older SPA implementations or shallow routing.
| 1 | <!-- hashchange: hash-based routing --> |
| 2 | <!DOCTYPE html> |
| 3 | <html lang="en"> |
| 4 | <head> |
| 5 | <meta charset="UTF-8" /> |
| 6 | <title>Hash-based SPA</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <nav> |
| 10 | <a href="#/home">Home</a> |
| 11 | <a href="#/about">About</a> |
| 12 | <a href="#/settings">Settings</a> |
| 13 | </nav> |
| 14 | <div id="view"></div> |
| 15 | |
| 16 | <script> |
| 17 | // Hash-based router |
| 18 | class HashRouter { |
| 19 | constructor(routes) { |
| 20 | this.routes = routes; |
| 21 | |
| 22 | // Listen for hash changes |
| 23 | window.addEventListener('hashchange', () => { |
| 24 | this.handleRoute(); |
| 25 | }); |
| 26 | |
| 27 | // Handle initial route |
| 28 | if (!window.location.hash) { |
| 29 | window.location.hash = '#/home'; |
| 30 | } |
| 31 | this.handleRoute(); |
| 32 | } |
| 33 | |
| 34 | handleRoute() { |
| 35 | const hash = window.location.hash.slice(1) || '/home'; |
| 36 | const route = this.routes[hash]; |
| 37 | |
| 38 | if (route) { |
| 39 | document.title = route.title; |
| 40 | document.getElementById('view').innerHTML = route.render(); |
| 41 | } else { |
| 42 | document.getElementById('view').innerHTML = '<h2>404</h2>'; |
| 43 | } |
| 44 | |
| 45 | // Update active link |
| 46 | document.querySelectorAll('nav a').forEach(a => { |
| 47 | a.classList.toggle('active', a.getAttribute('href') === window.location.hash); |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | navigate(hash) { |
| 52 | window.location.hash = hash; |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // Initialize router |
| 57 | const router = new HashRouter({ |
| 58 | '/home': { |
| 59 | title: 'Home', |
| 60 | render: () => '<h1>Home</h1><p>Hash-based routing.</p>', |
| 61 | }, |
| 62 | '/about': { |
| 63 | title: 'About', |
| 64 | render: () => '<h1>About</h1><p>This uses hashchange events.</p>', |
| 65 | }, |
| 66 | '/settings': { |
| 67 | title: 'Settings', |
| 68 | render: () => '<h1>Settings</h1><p>Configure your preferences.</p>', |
| 69 | }, |
| 70 | }); |
| 71 | |
| 72 | // hashchange event properties |
| 73 | // event.newURL → "https://example.com/#/about" |
| 74 | // event.oldURL → "https://example.com/#/home" |
| 75 | // window.location.hash → "#/about" |
| 76 | |
| 77 | // Comparing hashchange vs popstate: |
| 78 | // hashchange: fires when hash changes, simpler, broader support |
| 79 | // popstate: fires on any history navigation, works with pushState/replaceState |
| 80 | </script> |
| 81 | </body> |
| 82 | </html> |
pro tip
Single Page Applications use the History API to provide navigation without full page reloads. A router intercepts link clicks, calls pushState() to update the URL, renders the appropriate view, and listens for popstate to handle back/forward navigation. Proper SPA routing requires handling direct URL entry (deep linking) and coordinating with the server to serve the app shell for all routes.
| 1 | <!-- Complete SPA router with History API --> |
| 2 | <script> |
| 3 | class SPARouter { |
| 4 | constructor(routes, containerId) { |
| 5 | this.routes = routes; |
| 6 | this.container = document.getElementById(containerId); |
| 7 | |
| 8 | // Bind popstate |
| 9 | window.addEventListener('popstate', (e) => { |
| 10 | this.handleRoute(e.state?.path); |
| 11 | }); |
| 12 | |
| 13 | // Intercept all internal links |
| 14 | document.addEventListener('click', (e) => { |
| 15 | const link = e.target.closest('a'); |
| 16 | if (!link) return; |
| 17 | const href = link.getAttribute('href'); |
| 18 | if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) return; |
| 19 | |
| 20 | e.preventDefault(); |
| 21 | this.navigate(href); |
| 22 | }); |
| 23 | |
| 24 | // Handle initial route (direct URL or refresh) |
| 25 | this.handleRoute(window.location.pathname); |
| 26 | |
| 27 | // Sync initial state |
| 28 | if (!history.state || history.state?.path !== window.location.pathname) { |
| 29 | history.replaceState({ path: window.location.pathname }, '', window.location.pathname); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | handleRoute(path) { |
| 34 | path = path || window.location.pathname; |
| 35 | const route = this.resolveRoute(path); |
| 36 | |
| 37 | if (route) { |
| 38 | document.title = route.title; |
| 39 | this.container.innerHTML = route.render(); |
| 40 | this.updateActiveLinks(path); |
| 41 | } else { |
| 42 | this.renderNotFound(path); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | resolveRoute(path) { |
| 47 | // Direct match |
| 48 | if (this.routes[path]) return this.routes[path]; |
| 49 | |
| 50 | // Dynamic route matching /users/:id |
| 51 | for (const [pattern, route] of Object.entries(this.routes)) { |
| 52 | const paramNames = []; |
| 53 | const regexStr = pattern.replace(/:([^/]+)/g, (_, name) => { |
| 54 | paramNames.push(name); |
| 55 | return '([^/]+)'; |
| 56 | }); |
| 57 | const match = path.match(new RegExp(`^${regexStr}$`)); |
| 58 | if (match) { |
| 59 | const params = {}; |
| 60 | paramNames.forEach((name, i) => { params[name] = match[i + 1]; }); |
| 61 | route.params = params; |
| 62 | return route; |
| 63 | } |
| 64 | } |
| 65 | return null; |
| 66 | } |
| 67 | |
| 68 | navigate(path) { |
| 69 | const route = this.resolveRoute(path); |
| 70 | if (!route) return; |
| 71 | |
| 72 | history.pushState({ path }, route.title, path); |
| 73 | document.title = route.title; |
| 74 | this.container.innerHTML = route.render(); |
| 75 | this.updateActiveLinks(path); |
| 76 | } |
| 77 | |
| 78 | updateActiveLinks(path) { |
| 79 | document.querySelectorAll('nav a').forEach(link => { |
| 80 | link.classList.toggle('active', link.getAttribute('href') === path); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | renderNotFound(path) { |
| 85 | this.container.innerHTML = `<h1>404</h1><p>Route ${path} not found.</p>`; |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // Usage |
| 90 | const router = new SPARouter({ |
| 91 | '/': { title: 'Home', render: () => '<h1>Home</h1>' }, |
| 92 | '/about': { title: 'About', render: () => '<h1>About Us</h1>' }, |
| 93 | '/users': { title: 'Users', render: () => '<h1>Users List</h1>' }, |
| 94 | '/users/:id': { title: 'User', render: function() { |
| 95 | return `<h1>User ${this.params?.id}</h1>`; |
| 96 | }}, |
| 97 | '/products/:category/:id': { title: 'Product', render: function() { |
| 98 | return `<h1>${this.params?.category}: Product ${this.params?.id}</h1>`; |
| 99 | }}, |
| 100 | }, 'app'); |
| 101 | </script> |
| 102 | |
| 103 | <!-- Server configuration required (e.g., nginx) --> |
| 104 | <!-- All routes must serve index.html for deep linking --> |
| 105 | <!-- nginx.conf |
| 106 | location / { |
| 107 | try_files $uri $uri/ /index.html; |
| 108 | } |
| 109 | --> |
| 110 | |
| 111 | <!-- Apache .htaccess |
| 112 | RewriteEngine On |
| 113 | RewriteCond %{REQUEST_FILENAME} !-f |
| 114 | RewriteCond %{REQUEST_FILENAME} !-d |
| 115 | RewriteRule ^ index.html [QSA,L] |
| 116 | --> |
best practice
Browsers automatically restore scroll position when the user navigates back or forward through history. The history.scrollRestoration property controls this behavior. Setting it to "manual" disables automatic scroll restoration, giving developers full control. This is useful in SPAs where content above the fold may differ between navigations.
| 1 | <!-- Scroll restoration control --> |
| 2 | <script> |
| 3 | // Disable automatic scroll restoration |
| 4 | if ('scrollRestoration' in history) { |
| 5 | history.scrollRestoration = 'manual'; |
| 6 | } |
| 7 | |
| 8 | // Custom scroll restoration with state |
| 9 | function navigateAndSaveScroll(url) { |
| 10 | // Save current scroll position |
| 11 | const scrollPos = { |
| 12 | x: window.scrollX, |
| 13 | y: window.scrollY, |
| 14 | }; |
| 15 | |
| 16 | history.pushState( |
| 17 | { |
| 18 | ...history.state, |
| 19 | scrollPosition: scrollPos, |
| 20 | }, |
| 21 | '', |
| 22 | url |
| 23 | ); |
| 24 | |
| 25 | renderRoute(url); |
| 26 | // New page starts at top |
| 27 | window.scrollTo(0, 0); |
| 28 | } |
| 29 | |
| 30 | // Restore scroll on popstate |
| 31 | window.addEventListener('popstate', (event) => { |
| 32 | renderRoute(event.state?.url || '/'); |
| 33 | |
| 34 | // Restore saved scroll position |
| 35 | if (event.state?.scrollPosition) { |
| 36 | const { x, y } = event.state.scrollPosition; |
| 37 | // Use requestAnimationFrame to wait for render |
| 38 | requestAnimationFrame(() => { |
| 39 | window.scrollTo(x, y); |
| 40 | }); |
| 41 | } |
| 42 | }); |
| 43 | |
| 44 | // Enable smooth scroll restoration for specific cases |
| 45 | function enableScrollRestoration() { |
| 46 | history.scrollRestoration = 'auto'; |
| 47 | } |
| 48 | |
| 49 | // Save scroll position periodically (debounced) |
| 50 | let scrollTimer; |
| 51 | window.addEventListener('scroll', () => { |
| 52 | clearTimeout(scrollTimer); |
| 53 | scrollTimer = setTimeout(() => { |
| 54 | // Update current history entry with scroll position |
| 55 | history.replaceState( |
| 56 | { |
| 57 | ...history.state, |
| 58 | scrollPosition: { |
| 59 | x: window.scrollX, |
| 60 | y: window.scrollY, |
| 61 | }, |
| 62 | }, |
| 63 | '' |
| 64 | ); |
| 65 | }, 200); |
| 66 | }, { passive: true }); |
| 67 | </script> |
| 68 | |
| 69 | // Browser default behavior: |
| 70 | // scrollRestoration = 'auto' → browser restores scroll position |
| 71 | // scrollRestoration = 'manual' → developer handles scroll |
| 72 | |
| 73 | // Note: scroll restoration only works for same-origin navigations. |
| 74 | // Cross-origin navigation always starts at top. |
pro tip
Beyond pushState and replaceState, the History API provides methods for programmatic navigation: back(), forward(), and go(). These mimic the browser's navigation buttons. The length property reports the total number of entries in the session history stack.
| Method / Property | Description |
|---|---|
| history.back() | Navigate to the previous entry (like Back button) |
| history.forward() | Navigate to the next entry (like Forward button) |
| history.go(n) | Navigate n entries (-1 = back, 1 = forward, 0 = reload) |
| history.length | Number of entries in the session history stack |
| history.state | The state object of the current history entry |
| 1 | <!-- Programmatic navigation --> |
| 2 | <script> |
| 3 | // Navigation methods |
| 4 | function goBack() { |
| 5 | history.back(); |
| 6 | // Equivalent: history.go(-1) |
| 7 | } |
| 8 | |
| 9 | function goForward() { |
| 10 | history.forward(); |
| 11 | // Equivalent: history.go(1) |
| 12 | } |
| 13 | |
| 14 | function goBackSteps(n) { |
| 15 | history.go(-n); |
| 16 | } |
| 17 | |
| 18 | // Check if navigation is possible |
| 19 | function canGoBack() { |
| 20 | // history.length starts at 1 for the initial page |
| 21 | // So index 0 is always the first page |
| 22 | return history.length > 1; |
| 23 | } |
| 24 | |
| 25 | function canGoForward() { |
| 26 | // This is tricky: there's no direct API to check if forward is available |
| 27 | // We can track our own position |
| 28 | return false; // approximate — see custom tracking below |
| 29 | } |
| 30 | |
| 31 | // Custom history tracking for forward availability |
| 32 | const navStack = []; |
| 33 | let navIndex = -1; |
| 34 | |
| 35 | function trackNavigate(url) { |
| 36 | navIndex++; |
| 37 | navStack = navStack.slice(0, navIndex); |
| 38 | navStack.push(url); |
| 39 | } |
| 40 | |
| 41 | function customBack() { |
| 42 | if (navIndex > 0) { |
| 43 | navIndex--; |
| 44 | history.back(); |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | function customForward() { |
| 49 | if (navIndex < navStack.length - 1) { |
| 50 | navIndex++; |
| 51 | history.forward(); |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | function isForwardAvailable() { |
| 56 | return navIndex < navStack.length - 1; |
| 57 | } |
| 58 | |
| 59 | // Reload current page via go(0) |
| 60 | function reloadPage() { |
| 61 | history.go(0); |
| 62 | } |
| 63 | |
| 64 | // Custom back button handler with confirmation |
| 65 | function safeGoBack() { |
| 66 | if (canGoBack()) { |
| 67 | history.back(); |
| 68 | } else { |
| 69 | // Redirect to a safe fallback |
| 70 | window.location.href = '/'; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // History length monitoring |
| 75 | console.log('History entries:', history.length); |
| 76 | // Starts at 1 for the initial page |
| 77 | // Increases with pushState or regular navigation |
| 78 | // Never decreases (entries are not removed by going back) |
| 79 | |
| 80 | // Important: history.state is null for the initial page |
| 81 | // until pushState or replaceState is called |
| 82 | console.log('Current state:', history.state); |
| 83 | </script> |
info
The History API enforces a strict same-origin policy. You can only call pushState() and replaceState() with URLs that share the same origin (protocol + hostname + port) as the current page. Attempting to push a cross-origin URL throws a SecurityError DOMException. Additionally, the state object is limited to structured-cloneable data to prevent prototype pollution and injection attacks.
| Restriction | Details |
|---|---|
| Same-origin URL | pushState/replaceState URLs must match the current origin |
| Structured clone | State objects are serialized via structured clone (no functions, DOM nodes) |
| No cross-origin read | Scripts cannot read the history state of cross-origin pages |
| No URL spoofing | Browser URL bar cannot be spoofed — URL must pass same-origin check |
| State size limit | State objects are limited (640 KB in most browsers) — SecurityError if exceeded |
| 1 | <!-- Security considerations --> |
| 2 | <script> |
| 3 | // SAME-ORIGIN REQUIREMENT — these will throw: |
| 4 | |
| 5 | // Cross-origin (different hostname) |
| 6 | try { |
| 7 | history.pushState({}, '', 'https://evil.com/page'); |
| 8 | } catch (e) { |
| 9 | console.error('SecurityError:', e.message); |
| 10 | // "Failed to execute 'pushState' on 'History': A history state object |
| 11 | // with URL 'https://evil.com/page' cannot be created in a document |
| 12 | // with origin 'https://example.com'." |
| 13 | } |
| 14 | |
| 15 | // Cross-origin (different protocol) |
| 16 | try { |
| 17 | history.pushState({}, '', 'http://example.com/page'); |
| 18 | // Throws if current page is https |
| 19 | } catch (e) {} |
| 20 | |
| 21 | // Cross-origin (different port) |
| 22 | try { |
| 23 | history.pushState({}, '', 'https://example.com:8080/page'); |
| 24 | } catch (e) {} |
| 25 | |
| 26 | // State size limits — large state may throw |
| 27 | try { |
| 28 | const hugeState = new Array(1000000).fill('x'); |
| 29 | history.pushState({ data: hugeState }, ''); |
| 30 | } catch (e) { |
| 31 | // May throw if state exceeds browser's size limit |
| 32 | console.warn('State too large:', e.message); |
| 33 | } |
| 34 | |
| 35 | // SECURITY BEST PRACTICES |
| 36 | |
| 37 | // 1. Never put sensitive data in state |
| 38 | // State is accessible via history.state from any script on the page |
| 39 | // State may be written to disk (session restore files) |
| 40 | history.replaceState({ userId: 42, token: 'secret' }); // ✗ BAD |
| 41 | |
| 42 | // 2. Validate state on popstate |
| 43 | window.addEventListener('popstate', (e) => { |
| 44 | const state = e.state || {}; |
| 45 | |
| 46 | // Validate expected properties |
| 47 | if (typeof state.page !== 'string') { |
| 48 | // Malformed state — treat as initial load |
| 49 | renderRoute('/'); |
| 50 | return; |
| 51 | } |
| 52 | |
| 53 | // Sanitize URL before using |
| 54 | const allowedRoutes = ['/', '/about', '/contact']; |
| 55 | if (!allowedRoutes.includes(state.page)) { |
| 56 | state.page = '/'; |
| 57 | } |
| 58 | |
| 59 | renderRoute(state.page); |
| 60 | }); |
| 61 | |
| 62 | // 3. Don't rely on state for auth |
| 63 | // State is client-side and can be manipulated |
| 64 | // Always validate auth on the server |
| 65 | |
| 66 | // 4. Content Security Policy |
| 67 | // Use CSP headers to prevent XSS that could manipulate history |
| 68 | // Content-Security-Policy: script-src 'self' |
| 69 | </script> |
warning