The History API allows JavaScript to manipulate the browser's navigation history and URL without full page reloads. It's the mechanism that makes single-page applications (SPAs) feel like multi-page apps — different URLs for different views, working back/forward buttons, and bookmarkable states. Firefox implements the History API fully, and understanding it helps you build better SPAs and debug routing issues in Firefox DevTools.
The Core Problem History API Solves
Before the History API (HTML5), SPAs used the URL hash (#/users/123) for navigation. Hash changes don't trigger page reloads and don't affect the server, but they look ugly and don't work well with server-side rendering.
The History API allows changing the full URL path (/users/123) without a page reload, while maintaining proper browser history.
pushState and replaceState
// Add new history entry (creates a new back-button step)
history.pushState(stateObject, '', '/users/123');
// Replace current history entry (no new back-button step)
history.replaceState(stateObject, '', '/users/123');
Parameters:
-
State object: serializable object associated with this history entry (max 16MB in Firefox). Accessible later via
history.state. -
Title: mostly ignored by browsers. Pass
''. - URL: new URL (must be same origin). Can be absolute or relative path.
After calling pushState('/users/123'), the address bar shows /users/123, but no network request is made. The browser just updates the address bar and adds an entry to history.
Reading the Current State
// Current URL
location.pathname // '/users/123'
location.search // '?tab=info'
location.hash // '#section'
location.href // full URL
// Current history state (from the last pushState/replaceState)
history.state // the state object you passed
history.length // number of entries in history for this session
The popstate Event
popstate fires when the user navigates back/forward OR when history.back()/history.go() is called programmatically.
window.addEventListener('popstate', (event) => {
console.log('Navigated to:', location.pathname);
console.log('State:', event.state);
// Re-render the current view
renderPage(location.pathname, event.state);
});
Critical: popstate does NOT fire when pushState or replaceState is called. Only fires on back/forward navigation.
Building a Simple Router
class Router {
constructor(routes) {
this.routes = routes; // { '/': homeView, '/about': aboutView, ... }
// Handle back/forward
window.addEventListener('popstate', () => this.render(location.pathname));
// Handle link clicks
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href^="/"]');
if (link && !link.hasAttribute('target') && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.navigate(link.getAttribute('href'));
}
});
}
navigate(path) {
history.pushState({}, '', path);
this.render(path);
}
render(path) {
const route = this.routes[path] || this.routes['/404'];
if (route) route();
}
start() {
this.render(location.pathname);
}
}
const router = new Router({
'/': () => { document.getElementById('app').innerHTML = '<h1>Home</h1>'; },
'/about': () => { document.getElementById('app').innerHTML = '<h1>About</h1>'; },
'/404': () => { document.getElementById('app').innerHTML = '<h1>404</h1>'; }
});
router.start();
Server Configuration for SPA Routing
When using pushState, the server must serve your index.html for all routes. Otherwise, directly navigating to /users/123 returns a 404 because the server has no file at that path.
Nginx:
location / {
try_files $uri $uri/ /index.html;
}
Apache:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
Vite dev server: automatically handles this in development.
Saving Scroll Position
When navigating back, users expect to return to their previous scroll position:
// Save scroll position before navigating away
window.addEventListener('beforeunload', () => {
history.replaceState({ ...history.state, scrollY: window.scrollY }, '', location.href);
});
// Restore on popstate
window.addEventListener('popstate', (event) => {
renderPage(location.pathname);
// After rendering, restore scroll
if (event.state?.scrollY !== undefined) {
window.scrollTo(0, event.state.scrollY);
}
});
URL Parameters
The History API doesn't parse URL parameters — use URLSearchParams:
// Read parameters from current URL
const params = new URLSearchParams(location.search);
const page = params.get('page'); // '2'
const sort = params.get('sort'); // 'date'
// Build new URL with updated params
params.set('page', currentPage + 1);
history.pushState({}, '', `${location.pathname}?${params.toString()}`);
URLSearchParams is the correct way to handle query strings. Don't use string manipulation with .split('=') and .split('&') — it doesn't handle encoding correctly.
Debugging History in Firefox
DevTools doesn't have a History tab, but you can inspect history state:
// In DevTools console
history.state // current state object
history.length // stack length
// Listen for all navigation
window.addEventListener('popstate', console.log);
The address bar in Firefox updates immediately on pushState — watch it when debugging navigation.
history.scrollRestoration
Controls whether the browser automatically restores scroll position on back navigation:
// Disable browser's automatic scroll restoration (manage it yourself)
history.scrollRestoration = 'manual';
// Restore browser's automatic scroll restoration
history.scrollRestoration = 'auto'; // default
SPAs often set this to 'manual' to control scroll restoration behavior precisely — otherwise the browser may scroll to an incorrect position after re-rendering async content.
Top comments (0)