How to Log Out on Tab Close but Not on Refresh Using the pageswap Event
Recently I ran into a seemingly simple requirement:
When a user closes the browser tab, show a confirmation dialog; after confirming to leave, log them out. If an extension is bound, unbind the extension first. But do not log out when the page is refreshed.
At first I thought, isn't this just listening for a close event?
The more I researched, the more I discovered that closing a browser page is far more complex than imagined.
Phase 1: I wanted to show my own dialog
Initially, I wanted to show the project's own Element UI dialog when the user closed the tab:
this.$confirm(
"Are you sure you want to exit the system?",
"Prompt"
);
But I quickly realized this path was blocked.
Closing a tab or refreshing a page is a browser-level operation. Once the page enters the destruction process, the browser will not wait for a custom HTML dialog, nor will it wait for Promises, setTimeout, or async callbacks.
The only thing the browser allows is the beforeunload native confirmation dialog:
window.addEventListener("beforeunload", event => {
event.preventDefault();
event.returnValue = "";
});
And this native dialog:
- Cannot be styled;
- Cannot have its prompt text changed;
- Cannot directly listen for the "OK" button;
- Appears for refresh, tab close, and browser close alike.
So the first step was to accept reality: a custom dialog is impossible; only the browser's native dialog can be used.
Phase 2: Execute logout after clicking OK
Although there is no callback for the native dialog's "OK" button, there is another way to think about it.
If the user clicks Cancel, the page stays and pagehide does not fire; if the user clicks OK, the page proceeds to leave and pagehide fires afterward.
So the first version was born:
handleBeforeUnload(event) {
event.preventDefault();
event.returnValue = "";
},
handlePageHide() {
this.logout();
}
It looked reasonable:
beforeunload dialog
→ user confirms
→ pagehide
→ execute logout
But testing immediately revealed a new problem: refreshing the page also logged out.
Because both refresh and close go through:
beforeunload → pagehide
Phase 3: Online methods all seemed usable
To distinguish refresh from close, I started searching for various solutions online.
Using event time difference
Some articles judge by calculating the time difference between beforeunload and unload:
if (unloadTime - beforeunloadTime <= 1) {
// treat as close
}
But the event interval is affected by the browser, computer performance, and page load. 1ms has no standard basis; both refresh and close can be misjudged.
Using mouse coordinates
Other articles judge based on whether the mouse is in the upper-right corner of the browser:
const n =
window.event.screenX - window.screenLeft;
if (
n > document.documentElement.scrollWidth - 20
) {
// guess the user clicked the close button
}
This method is even less reliable.
The browser tab bar is not part of the webpage DOM; the page cannot get the exact coordinates of the browser's close button. Moreover, keyboard shortcuts, touchscreens, system scaling, and side tab bars are all uncovered.
Using PerformanceNavigationTiming
This seemed the most reasonable:
const entry =
performance.getEntriesByType("navigation")[0];
if (entry.type !== "reload") {
// treat as close
}
But upon closer analysis, entry.type indicates:
How the current page was originally loaded.
It does not indicate:
Whether the user is about to refresh or close this time.
For example, if the page was initially opened via a link, the type is navigate. If the user now clicks refresh, the old page still reads navigate before destruction. Only the new page after the refresh completes has the type reload.
But by then the old page has already executed the logout.
So these methods can only "guess" and cannot be reliably used for an important operation like logout.
Phase 4: I once thought it was truly impossible to distinguish
At this point in my research, my conclusion had become:
The browser internally may know whether the user is refreshing or closing, but it does not expose this information to the webpage, so the frontend cannot reliably distinguish them.
Until during testing, I noticed a detail:
- On refresh, Chrome's native dialog shows "Reload this site";
- On close, Chrome's native dialog shows "Leave this site".
Since the browser displays different text, it means the browser internally definitely knows the type of this operation.
The only question left: is there a new Web API that can get this information?
Phase 5: Discovering pageswap
After further searching for new browser APIs, I discovered the pageswap event.
Its activation.navigationType can return:
"push"
"replace"
"reload"
"traverse"
Where:
event.activation.navigationType === "reload"
means this page transition is a refresh.
So I thought the problem was solved:
handlePageSwap(event) {
this.isReload =
event.activation.navigationType === "reload";
}
But after the first implementation, refresh still logged out.
Phase 6: The idea was right, but the event order was wrong
Initially I thought the event order was:
pageswap
→ beforeunload
→ pagehide
So I read the reload flag inside beforeunload.
After adding logs in local Chrome 150, I found the actual order is:
beforeunload
→ user confirms
→ pageswap
→ pagehide
That is, when beforeunload fires, pageswap has not yet told the page this is a refresh.
If the judgment is made in beforeunload, it inevitably gets the old value.
This is also the most critical point of the whole problem:
beforeunloadis only responsible for showing the dialog, not for deciding whether to log out. The final decision must be placed inpagehide.
Final implementation
<script>
import { getToken } from "@/utils/auth";
export default {
data() {
return {
browserLeaveRequested: false,
browserLeaveIsReload: false,
browserLogoutStarted: false
};
},
mounted() {
window.addEventListener(
"beforeunload",
this.handleBeforeUnload
);
window.addEventListener(
"pageswap",
this.handlePageSwap
);
window.addEventListener(
"pagehide",
this.handlePageHide
);
},
methods: {
// Only responsible for triggering the browser's native confirmation dialog
handleBeforeUnload(event) {
if (!getToken()) return;
this.browserLeaveRequested = true;
this.browserLeaveIsReload = false;
event.preventDefault();
event.returnValue = "";
},
// After user confirms, identify whether this operation is a refresh
handlePageSwap(event) {
const activation = event.activation;
this.browserLeaveIsReload =
!!activation &&
activation.navigationType === "reload";
},
// Finally decide whether to log out
handlePageHide() {
// Refresh page, do not log out
if (this.browserLeaveIsReload) return;
if (
!this.browserLeaveRequested ||
this.browserLogoutStarted
) {
return;
}
this.browserLogoutStarted = true;
this.logoutWhenBrowserLeaves();
},
logoutWhenBrowserLeaves() {
const token = getToken();
if (!token) return;
fetch("/api/auth/logout", {
method: "POST",
headers: {
Authorization: token
},
credentials: "include",
keepalive: true
}).catch(() => {});
this.$store.dispatch("FedLogOut");
}
},
beforeDestroy() {
window.removeEventListener(
"beforeunload",
this.handleBeforeUnload
);
window.removeEventListener(
"pageswap",
this.handlePageSwap
);
window.removeEventListener(
"pagehide",
this.handlePageHide
);
}
};
</script>
The final flow is as follows.
Refresh page:
beforeunload shows "Reload this site"
→ user confirms
→ pageswap gets reload
→ pagehide detects refresh
→ skip logout
Close tab:
beforeunload shows "Leave this site"
→ user confirms
→ no reload flag
→ pagehide
→ unbind extension and log out
Click cancel:
beforeunload dialog
→ user cancels
→ page continues running
→ final logout not triggered
Insights from this investigation
The most interesting part of this problem is not which API was used in the end, but the entire cognitive shift:
Thought a single beforeunload was enough
→ Wanted to use a custom dialog
→ Found only native dialog is possible
→ Tried to log out inside beforeunload
→ Found refresh also logs out
→ Tried time difference, mouse coordinates, Performance API
→ Found these solutions are all guessing
→ Once thought the browser completely hides the ability to distinguish
→ Found a breakthrough from the native dialog text difference
→ Discovered pageswap
→ Finally tripped up by the event execution order
→ Confirmed the final solution through real logs
Many frontend problems are not about "whether there is an API", but about:
- Whether the API expresses a past state or current intent;
- What the execution order of multiple events is;
- What can be done before and after user confirmation;
- Which async operations can still continue during the page destruction phase.
Only by truly clarifying these timings can code go from "occasionally effective" to "logically correct".
Finally, a reminder: pageswap is a relatively new browser capability; check target browser compatibility before use. For abnormal situations like browser crashes or system forced process termination, frontend events still cannot guarantee execution. Systems with higher requirements should ideally combine backend heartbeats, session expiration, or delayed logout mechanisms.