When users try to leave a web application by pressing the browser's back button, you might want to show them a confirmation dialog to prevent accidental navigation. While window.onbeforeunload
can handle page refresh or tab closing, it doesn't work for browser back/forward navigation. That's where the Popstate API comes in handy.
In this article, I'll show you how to build a sophisticated exit confirmation system using the Popstate API with TypeScript and Vite.
What is the Popstate API?
The popstate
event is fired when the user navigates through their session history (like clicking the back/forward buttons). Unlike beforeunload
, it gives us full control over the user experience and allows us to show custom modals instead of browser-native dialogs.
Understanding Browser Navigation Events
To fully grasp why popstate is so powerful, let's compare it with other navigation events:
beforeunload
Event:
- Triggers when page is about to be unloaded (refresh, close tab, navigate away)
- Shows browser's native dialog (can't customize appearance)
- Limited to simple text messages in modern browsers
- Doesn't trigger for back/forward button navigation
unload
Event:
- Fires when page is actually being unloaded
- No user interaction possible (too late to prevent)
- Mainly for cleanup tasks
popstate
Event:
- Fires when user navigates through session history
- Perfect for back/forward button interactions
- Allows complete custom UI control
- Can prevent the navigation entirely
The Browser History Stack
Every browser maintains a history stack that looks like this:
[google.com] → [github.com] → [your-app.com] → [settings-page]
↑ current position
When a user:
- Clicks a link: New entry is pushed to the stack
- Presses back: Browser pops the current entry and navigates to previous
- Presses forward: Browser moves forward in the stack
-
Uses
history.pushState()
: We can manipulate this stack programmatically
Why Traditional Approaches Fall Short
Problem with beforeunload
:
// This WON'T work for back button!
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
return 'Are you sure you want to leave?'; // Only works for page refresh/close
});
Problem with URL hash tricks:
// Old approach - unreliable and affects URL
window.location.hash = '#preventBack';
window.addEventListener('hashchange', (e) => {
// Messy, affects browser history, not user-friendly
});
Our popstate solution advantages:
- ✅ Works specifically for back/forward navigation
- ✅ Completely custom UI/UX
- ✅ No URL pollution
- ✅ Clean, predictable behavior
- ✅ Works across all modern browsers
Project Setup
I'm using a minimal Vite + TypeScript setup for this project:
{
"name": "popstate_app",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
The Core Implementation
Here's the main popstate handler that creates our exit confirmation system:
export function setupPopstateHandler() {
// Navigation state flag - prevents premature triggering during setup
let hasNavigated = false;
/**
* Creates and displays the exit confirmation modal
* Reuses existing modal if already created for performance
*/
const showExitSection = () => {
// Check if modal already exists to avoid recreating DOM elements
const existingSection = document.getElementById('exit-section');
if (existingSection) {
existingSection.style.display = 'block';
return; // Early return - no need to recreate
}
// Create the modal structure
const section = document.createElement('section');
section.id = 'exit-section';
// Build the modal HTML with semantic structure
section.innerHTML = `
<div class="exit-overlay">
<div class="exit-modal">
<h2>Do you really want to leave?</h2>
<p>You are about to leave this page.</p>
<div class="exit-buttons">
<button id="stay-btn" class="btn-primary">Stay</button>
<button id="leave-btn" class="btn-secondary">Leave</button>
</div>
</div>
</div>
`;
// Apply styles for full-screen overlay with high z-index
section.style.cssText = `
position: fixed; /* Fixed positioning to cover viewport */
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000; /* High z-index to appear above all content */
display: block;
`;
// Add modal to DOM
document.body.appendChild(section);
// Get references to the action buttons
const stayBtn = document.getElementById('stay-btn');
const leaveBtn = document.getElementById('leave-btn');
/**
* "Stay" button handler
* Hides modal and recreates history buffer for next back button press
*/
stayBtn?.addEventListener('click', () => {
section.style.display = 'none';
// Critical: Re-establish the history buffer for the next back press
history.pushState(null, '', window.location.href);
});
/**
* "Leave" button handler
* Implements multiple exit strategies with graceful fallbacks
*/
leaveBtn?.addEventListener('click', () => {
// STRATEGY 1: Direct window closure
// Only works if window was opened by JavaScript
try {
window.close();
// Note: If this succeeds, the code below won't execute
} catch (e) {
console.log('window.close() failed:', e);
}
// STRATEGY 2: Navigate back in history
// Check if there's actual navigation history available
if (window.history.length > 1) {
window.history.back();
return; // Exit early if this method is available
}
// STRATEGY 3: Force navigation using history.go()
// Alternative method for browsers that restrict history.back()
try {
window.history.go(-1);
} catch (e) {
console.log('history.go(-1) failed:', e);
}
// STRATEGY 4: Redirect to blank page (delayed execution)
// Timeout allows previous strategies to complete first
setTimeout(() => {
try {
// about:blank is the cleanest exit page
window.location.replace('about:blank');
} catch (e) {
// Fallback: Create a custom closing page with auto-close attempt
window.location.replace(
'data:text/html,<html><body><h1>Page closed</h1><script>window.close();</script></body></html>'
);
}
}, 100); // 100ms delay to prevent race conditions
// STRATEGY 5: Content replacement (final fallback)
// Always works since it's just DOM manipulation
setTimeout(() => {
document.body.innerHTML = `
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: Arial;
">
<h1>Page closed - You can close this tab</h1>
</div>
`;
document.title = 'Page closed';
}, 200); // 200ms delay ensures all other strategies have been attempted
});
};
/**
* Popstate event handler
* Triggered when user navigates with back/forward buttons
*/
const handlePopstate = () => {
// Only act if we've completed initial setup
if (hasNavigated) {
showExitSection();
// Immediately re-establish history buffer to prevent actual navigation
history.pushState(null, '', window.location.href);
}
};
// INITIALIZATION SEQUENCE (order is crucial!)
// 1. Create initial history buffer entry
// This creates a "fake" history entry that we can intercept
history.pushState(null, '', window.location.href);
// 2. Mark that we're ready to handle navigation
// Prevents premature triggering during setup
hasNavigated = true;
// 3. Start listening for popstate events
// Now we'll catch any back/forward button presses
window.addEventListener('popstate', handlePopstate);
// Return cleanup function for proper memory management
// Call this when component unmounts or page changes
return () => {
window.removeEventListener('popstate', handlePopstate);
// Optional: Remove modal from DOM if it exists
const existingSection = document.getElementById('exit-section');
if (existingSection) {
existingSection.remove();
}
};
}
How It Works
Understanding the core logic behind this implementation requires diving deep into browser history manipulation and the popstate event lifecycle.
1. The History Manipulation Trick
// Initial setup - this is the key to the entire mechanism
history.pushState(null, '', window.location.href);
hasNavigated = true;
What's happening here?
When a user first loads our page, the browser history looks like this:
[Previous Page] → [Our Page]
By calling history.pushState()
, we're essentially duplicating the current page in the history stack:
[Previous Page] → [Our Page] → [Our Page (duplicate)]
This creates a "buffer" entry. Now when the user presses the back button, instead of leaving our application entirely, they'll navigate to our duplicate entry, which triggers the popstate event while keeping them on our page.
Why null
and the current URL?
-
null
as state: We don't need to store any specific state data - Current URL: We want to stay on the same page, just create a history entry
- The URL doesn't change visually for the user
2. The Popstate Event Lifecycle
const handlePopstate = () => {
if (hasNavigated) {
showExitSection();
history.pushState(null, '', window.location.href);
}
};
Let's break down what happens step-by-step when the user presses back:
Step 1: User presses back button
- Browser attempts to navigate to the previous history entry
- This triggers the
popstate
event
Step 2: Our handler intercepts the navigation
-
handlePopstate()
is called - We check if
hasNavigated
is true (ensuring we only act after initial setup)
Step 3: Show modal and re-establish history buffer
-
showExitSection()
displays the confirmation modal -
history.pushState()
immediately creates a new buffer entry - The user never actually leaves the page
Visual representation:
User on page: [Previous] → [Our Page] → [Buffer]
↑ user is here
User presses back: [Previous] → [Our Page] ← [Buffer]
↑ browser tries to go here
Our handler: Show modal + [Previous] → [Our Page] → [New Buffer]
↑ user stays here
4. Smart Exit Strategies Deep Dive
The exit logic implements a waterfall approach because browsers have different capabilities and restrictions:
leaveBtn?.addEventListener('click', () => {
// Strategy 1: Direct window closure
try {
window.close();
} catch (e) {
console.log('window.close() failed:', e);
}
// Strategy 2: Navigate back if history exists
if (window.history.length > 1) {
window.history.back();
return;
}
// Strategy 3: Force navigation
try {
window.history.go(-1);
} catch (e) {
console.log('history.go(-1) failed:', e);
}
// Strategy 4: Redirect to blank page
setTimeout(() => {
try {
window.location.replace('about:blank');
} catch (e) {
window.location.replace('data:text/html,<html><body><h1>Page closed</h1><script>window.close();</script></body></html>');
}
}, 100);
// Strategy 5: Clear page content
setTimeout(() => {
document.body.innerHTML = '<div style="..."><h1>Page closed</h1></div>';
document.title = 'Page closed';
}, 200);
});
Why multiple strategies?
-
window.close()
: Only works if:- Window was opened by JavaScript (
window.open()
) - User has allowed popup closing in browser settings
- Not the last tab in the browser
- Window was opened by JavaScript (
-
history.back()
/history.go(-1)
: Works when:- There's actual navigation history
- Browser allows programmatic navigation
- Not restricted by security policies
-
about:blank
redirect: A universal fallback:- Works in all browsers
- Provides clean exit experience
- Shows user the page is "closed"
-
Data URI with auto-close: Backup for restricted environments:
- Creates a page that attempts to close itself
- Shows clear message to user
-
Content replacement: Final fallback:
- Always works since it's just DOM manipulation
- Provides clear feedback that page is "closed"
- User can manually close the tab
Timing considerations:
-
setTimeout
delays allow previous operations to complete - 100ms and 200ms intervals prevent race conditions
- Gives browsers time to process each strategy before falling back
5. Modal State Management
const showExitSection = () => {
const existingSection = document.getElementById('exit-section');
if (existingSection) {
existingSection.style.display = 'block';
return;
}
// ... create new modal
};
Why check for existing modal?
- Performance: Avoid recreating DOM elements unnecessarily
- Event listeners: Prevent duplicate event handlers
- Memory management: Reduce DOM bloat
- State preservation: Modal animations and styles remain consistent
The "Stay" button logic:
stayBtn?.addEventListener('click', () => {
section.style.display = 'none';
history.pushState(null, '', window.location.href);
});
When user chooses to stay:
- Hide the modal (don't remove it for reuse)
- Create a new history buffer for the next potential back press
- User continues normally on the page
Complete Flow Diagram
Here's a visual representation of the entire process:
┌─────────────────────────────────────────────────────────────────┐
│ INITIALIZATION PHASE │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────┐
│ Page loads normally │
│ History: [Previous][Page] │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ history.pushState() called │
│ History: [Previous][Page] │
│ [Buffer] │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ hasNavigated = true │
│ Event listener added │
└─────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ USER INTERACTION │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────┐
│ User presses BACK │
│ button │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ Browser attempts to │
│ navigate to [Page] │
│ from [Buffer] │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ popstate event fires │
│ handlePopstate() called │
└─────────────────────────────┘
│
┌─────────────┐
│hasNavigated?│
└─────────────┘
│
┌────────┴─────────┐
YES NO (ignore)
│ │
v v
┌─────────────────────────────┐ [END]
│ showExitSection() │
│ Display modal │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ history.pushState() │
│ Create new buffer │
│ History: [Previous][Page] │
│ [Buffer2] │
└─────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ USER CHOICE │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────┴────────────┐
│ │
[STAY BUTTON] [LEAVE BUTTON]
│ │
v v
┌─────────────────────────┐ ┌─────────────────────────┐
│ Hide modal │ │ Execute exit │
│ pushState() again │ │ strategies 1-5 │
│ Ready for next back │ │ (waterfall approach) │
│ [END - User stays] │ │ │
└─────────────────────────┘ └─────────────────────────┘
│
┌─────────────┴──────────────┐
│ │
[Success Exit] [Final Fallback]
│ │
[Page closed] [Content replaced]
[User closes tab]
┌─────────────────────────────────────────────────────────────────┐
│ EXIT STRATEGIES │
└─────────────────────────────────────────────────────────────────┘
Strategy 1: window.close()
├─ IF window opened by script → ✅ Close window
└─ ELSE → Continue to Strategy 2
Strategy 2: history.back()
├─ IF history.length > 1 → ✅ Navigate back
└─ ELSE → Continue to Strategy 3
Strategy 3: history.go(-1)
├─ IF not restricted → ✅ Navigate back
└─ ELSE → Continue to Strategy 4
Strategy 4: about:blank redirect (100ms delay)
├─ IF about:blank allowed → ✅ Clean exit page
└─ ELSE → data:text/html with auto-close
Strategy 5: Content replacement (200ms delay)
└─ ✅ Always works → Show "Page closed" message
This flowchart shows how the system gracefully handles different scenarios and provides multiple fallback options to ensure the best possible user experience across all browsers and contexts.
Styling the Modal
The CSS creates a professional-looking modal with overlay:
.exit-overlay {
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.exit-modal {
background-color: #1a1a1a;
border-radius: 8px;
padding: 2rem;
max-width: 400px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.exit-buttons {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-primary {
background-color: #646cff;
color: white;
border: none;
}
.btn-secondary {
background-color: transparent;
color: #888;
border: 1px solid #888;
}
Integration
Setting up the popstate handler in your main application is simple:
import { setupPopstateHandler } from './popstate.ts'
// Initialize the popstate handler
setupPopstateHandler()
Key Benefits
- Better UX: Custom modal instead of browser dialog
- Cross-browser compatibility: Multiple fallback strategies
- TypeScript support: Full type safety
- Performance: Event listeners are properly cleaned up
- Flexibility: Easy to customize the modal content and styling
Browser Considerations
- Safari: May restrict some navigation methods
- Chrome/Edge: Generally supports all fallback strategies
- Firefox: Good support with minor differences in behavior
-
Mobile browsers: Limited support for
window.close()
Conclusion
The Popstate API provides a powerful way to create custom navigation experiences. This implementation shows how to build a robust exit confirmation system that handles various browser behaviors gracefully.
The key is to:
- Manipulate browser history to intercept back button presses
- Provide multiple fallback strategies for different browsers
- Create a user-friendly modal interface
- Clean up event listeners properly
You can find the complete source code for this project on GitHub and see it in action in the live demo.
Top comments (0)