If you left the dashboard open in a tab for a while, came back and clicked a menu item, you may have seen the page you were already on reload instead of the page you clicked. A second click worked, so it was easy to shrug off, but it happened often enough to be one of the most common rough edges in the dashboard. It's fixed now, along with a related problem where a tab left open across one of our deploys would ignore clicks entirely.
This post covers both bugs and what we learned about handling session timeouts in a Vue single-page app.
How the dashboard keeps you signed in
The dashboard is a Vue 3 single-page app served by a Spring Boot backend. Two cookies keep you signed in:
- A session cookie. The server forgets the session after a period of inactivity.
- A longer-lived remember-me cookie. It lets the server start a new session for you without asking for your password again.
When the session has expired but the remember-me cookie is still valid, we want you to notice nothing at all. The way we do that is a full page load: the browser makes a normal request to the server, the server signs you back in from the remember-me cookie, and it sends back a fresh copy of the app.
Where the check happens: the router guard
Every dashboard page needs you to be signed in, so the check lives in a Vue Router beforeEach guard, which runs before each navigation:
router.beforeEach(async (to, from, next) => {
const isAuthenticated = await authService.checkAuthentication()
if (isAuthenticated) {
next()
return
}
// ...session expired: do a full page load so remember-me can sign the user back in
})
checkAuthentication() asks the server who you are. Its answer is cached for one minute, so clicking around the dashboard doesn't cost a server round trip on every click. The trade-off is that an expired session can go unnoticed for up to a minute. That's fine, because the API calls a page makes have their own check (more on that below).
The bug: reloading from instead of going to to
Here is what the "session expired" branch used to do:
window.location.reload()
A guard gets two locations: from, the page you're on, and to, the page you clicked. location.reload() knows nothing about the router. It reloads the address in the address bar, which is still from, because the guard runs before the navigation happens.
So remember-me did its job and you were signed back in, but on the page you were trying to leave. No error and no login screen made it look like the click had been ignored.
The fix is to do the full page load to the target instead:
window.location.assign(router.resolve(to.fullPath).href)
next(false)
Two details matter here:
router.resolve(...).href, notto.fullPath. The dashboard's router uses a base path (createWebHistory("/dashboard")).to.fullPathis relative to that base (/qr-codes), whilehrefis the real browser address (/dashboard/qr-codes). PassingfullPathtolocation.assignwould send you to the wrong URL.next(false)cancels the in-app navigation while the browser leaves the page, so the router doesn't half-render the target in the meantime.
Never loop
A full page load to sign someone back in has an obvious failure mode. If the remember-me cookie has also expired, the new page loads, the guard finds you still signed out, it loads the page again, and so on forever.
We guard against that with a timestamp in sessionStorage:
const lastReload = sessionStorage.getItem('auth_reload_time')
const now = Date.now()
if (!lastReload || (now - parseInt(lastReload)) > 10000) {
sessionStorage.setItem('auth_reload_time', now.toString())
window.location.assign(router.resolve(to.fullPath).href)
next(false)
return
}
// Already tried in the last 10 seconds and still signed out: send them to the login page
sessionStorage.removeItem('auth_reload_time')
sessionStorage.setItem('redirectAfterLogin', to.fullPath)
authService.redirectToLogin()
sessionStorage suits this job. It survives a page load, which an in-memory variable doesn't, and it belongs to one tab, so two tabs that expire at the same moment don't block each other. If the second attempt still finds no session, you go to the login page, and we remember where you were going so the dashboard can take you there after you sign in.
The other place sessions expire: API calls
The router guard only runs when you navigate. If you sit on a page and click Save after your session has expired, the API returns 401. A response interceptor on our HTTP client handles that:
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// same 10-second guard as the router...
window.location.reload()
return new Promise(() => {}) // never settle: the page is going away
}
return Promise.reject(error)
}
)
Here, reloading the current page is right, because the request came from the page you're on. The interesting line is the last one. Returning a promise that never settles means the component that made the call never sees an error, so it doesn't flash an "Unable to save" message in the moment before the page goes away.
The related bug: a tab open across a deploy
The second problem had the same trigger, an old tab, but a different cause.
Vite splits the app into one JavaScript file per page and loads each one the first time you visit that page:
{ path: "/qr-codes", component: () => import("../views/QRCodeManagement.vue") }
Each file name includes a hash of its contents (QRCodeManagement-CiHfc1cA.js), so browsers can cache them forever. When we deploy, the hashes change and the old files are gone. A tab opened before the deploy still runs the old app, and the old app asks for the old file names. The request fails, the navigation is cancelled, and nothing happens. There's no reload this time, just a click that does nothing.
Vue Router reports this through router.onError, which also receives the location it was trying to reach. Recognising the error takes a little care, because each browser words it differently:
- Chrome: "Failed to fetch dynamically imported module"
- Safari: "Importing a module script failed"
- Firefox: "error loading dynamically imported module"
- Vite's CSS preloading: "Unable to preload CSS"
router.onError((error, to) => {
const isChunkLoadError = /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i
.test(error?.message || '')
if (!isChunkLoadError || !to) return
// same 10-second loop guard, with its own key
window.location.assign(router.resolve(to.fullPath).href)
})
A full page load to the target fetches the new app, which knows the new file names. It only helps if that page load really gets the new index.html, so the dashboard's HTML is sent with Cache-Control: no-store. The hashed JavaScript files can stay cached forever; the HTML that names them must not be cached.
A checklist for your own Vue app
If your Vue app uses cookie sessions, these are the things worth checking:
- When you redirect from a router guard, go to
to, neverlocation.reload(). The reload goes to the page the user is leaving. - Use
router.resolve(to.fullPath).hrefif your router has a base path. - Put a loop guard on every automatic page load, keyed in
sessionStorage. - Handle
401in an HTTP interceptor too, because the guard only runs on navigation. Hang the promise while the page goes away. - Handle lazy-loaded files that fail to load in
router.onError, matching every browser's wording. - Never cache
index.html. Cache the hashed files as long as you like.
How we tested it
Both cases can be reproduced in Playwright without waiting hours:
- Expired session: delete the session cookie but keep the remember-me cookie, wait just over a minute so the router's auth cache runs out, then navigate. The browser should land on the target page with a new session cookie.
- Deploy while open: use
page.route()to answer the target page's JavaScript file with a404once, then navigate. The app should do a full load of the target page and render it.
Both now land on the page you clicked.