|$ curl https://forge-ai.dev/api/markdown?path=docs/html/history-api
$cat docs/history-api.md
updated This week·35 min read·published

History API

HTMLHistory APIIntermediateIntermediate
Introduction

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.

History Stack

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.

ConceptDescription
Session historyThe ordered list of documents visited in the current tab
Current entryThe top entry on the stack — what the user is viewing now
History.lengthNumber of entries in the session history stack
History.scrollRestorationControls whether scroll position is restored on navigation
State objectSerializable data associated with a history entry

best practice

The history stack is specific to the current tab or frame. Opening a new tab starts a fresh history stack. The history.length property reflects the total entries visited in the current session, starting at 1 for the initial page.
pushState

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.

pushstate.html
HTML
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

The state object must be serializable via the structured clone algorithm — it can contain primitives, arrays, plain objects, Map, Set, Date, RegExp, Blob, ArrayBuffer, and typed arrays. It cannot contain functions, DOM elements, or cyclic references. The state is preserved across page reloads in some browsers (Chrome, Firefox) but not guaranteed.
replaceState

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.

replacestate.html
HTML
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:

preview
popstate Event

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.

popstate.html
HTML
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 popstate event does not fire for the initial page load — only for subsequent navigations through the history. If you need to restore state on page load, read history.state directly in your initialization code. Also note that calling history.back() or history.forward() from JavaScript triggers popstate synchronously, before the function returns.
hashchange Event

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.

hashchange.html
HTML
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

Modern SPAs should prefer pushState/replaceState over hash-based routing for clean URLs. However, hash-based routing is still useful for deep-linking within a page (e.g., #faq-section) where you want the browser's native scroll-into-view behavior combined with history entries. Hash fragments are never sent to the server, making them inherently safe for client-only routing.
SPA Routing

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.

spa-routing.html
HTML
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
106location / {
107 try_files $uri $uri/ /index.html;
108}
109-->
110
111<!-- Apache .htaccess
112RewriteEngine On
113RewriteCond %{REQUEST_FILENAME} !-f
114RewriteCond %{REQUEST_FILENAME} !-d
115RewriteRule ^ index.html [QSA,L]
116-->

best practice

SPA routing requires server-side support — all application routes must serve the same index.html file so that deep links (including page refreshes on non-root URLs) work correctly. Without this configuration, users will get a 404 when refreshing /about or sharing a deep link. Frameworks like Next.js, Remix, and Nuxt handle this automatically with their hybrid rendering approaches.
Scroll Restoration

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.

scroll-restoration.html
HTML
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

In SPAs, set history.scrollRestoration = "manual" and manage scroll positions yourself by saving them in the state object. This gives you consistent behavior across browsers and lets you implement smooth scroll-to-top on navigation while preserving back-button scroll positions. Always use requestAnimationFrame to restore scroll after the new content has rendered.
Manipulating History

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 / PropertyDescription
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.lengthNumber of entries in the session history stack
history.stateThe state object of the current history entry
history-manipulation.html
HTML
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

There is no direct API to check if forward navigation is available. Track your own navigation stack if you need forward button state. The history.length property only tells you the total stack size, not your position within it. A common pattern is to maintain a parallel array of URLs that you push to on pushState and pop from on popstate.
Security

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.

RestrictionDetails
Same-origin URLpushState/replaceState URLs must match the current origin
Structured cloneState objects are serialized via structured clone (no functions, DOM nodes)
No cross-origin readScripts cannot read the history state of cross-origin pages
No URL spoofingBrowser URL bar cannot be spoofed — URL must pass same-origin check
State size limitState objects are limited (640 KB in most browsers) — SecurityError if exceeded
history-security.html
HTML
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

Never store sensitive information (auth tokens, personal data, session IDs) in history state objects. The state is accessible via history.state from any JavaScript running on the page, and in some browsers, state is persisted to disk as part of session restore files. Treat history state as publicly readable client-side data.
Best Practices

History API Checklist

Use pushState for new navigation entries, replaceState for updating current entry (params, modals, tabs)
Always handle popstate to support back/forward navigation in SPAs
Initialize route from window.location.pathname on page load to support deep linking
Set history.scrollRestoration = 'manual' in SPAs and manage scroll yourself
Save scroll position in the state object and restore it in popstate handler
Configure the server to serve index.html for all routes (SPA fallback)
Validate and sanitize state objects in popstate handlers — never trust stored state
Never put sensitive data (tokens, PII) in history state
Use a router library (React Router, Vue Router, TanStack Router) for complex routing
Track navigation position manually if you need forward-button availability state
Use hashchange as a simpler alternative for shallow routing or anchor navigation
Test back/forward behavior thoroughly — it's easy to create broken navigation loops
Keep state objects small — large state may hit browser limits or degrade serialization performance

Related Topics

$Blueprint — Engineering Documentation·Section ID: HTML-33·Revision: 1.0