onUnmounted isn't firing in my Inertia.js app — here's why
You set up an interval in onMounted, clear it in onUnmounted, and move on. Standard Vue. Then a user reports that your dashboard is "kind of slow and my laptop fan is spinning" after they've navigated around the app for a while. You open DevTools and discover that your polling function is running not once but seven times per second, because every time they visited the page a new interval started and none of the old ones got cleared.
Your onUnmounted never fired. Welcome to one of Inertia.js's stickier behaviours.
What's actually happening
Inertia isn't a full SPA framework — it's a thin layer that lets Laravel return something that looks like a Vue page instead of HTML. When you navigate in an Inertia app, here's what happens at the Vue lifecycle level:
- The user clicks a link
- Inertia fetches the new page's props from Laravel
- The same page component instance may be reused if the next page is the same component type
- Only the props change; Vue treats it as a reactive update
This is great for performance — no teardown-and-rebuild cycle between visits to the same component. But it means that when you navigate from one page to another rendered by the same Vue component (or when Inertia decides to preserve an instance), onUnmounted does not fire, because the component is not actually unmounted.
The classic case: you have a Dashboard.vue page. You navigate from /dashboard/team-a to /dashboard/team-b. Both URLs render the Dashboard.vue component. Inertia keeps the instance, swaps the props, and calls it a day. Your mounted hook runs once (on the first visit), sets up an interval, and your unmounted hook never fires because the component is still mounted — you just navigated between two views that happen to share it.
The symptoms
A few things you'll typically see:
- Intervals, timeouts, or event listeners that accumulate with every page visit
- Memory usage growing the longer the user stays in the app
- WebSocket connections that should have closed but haven't
- Keyboard shortcuts from an old page still firing on a new page
- API polling that starts running at absurd frequencies
If you've seen any of these in an Inertia app and blamed it on "Vue weirdness", this is probably the real cause.
The fix
You have three options, in increasing order of framework-awareness.
Option 1: Use onBeforeUnmount — still doesn't help
Just to get this out of the way: onBeforeUnmount has the same problem. Neither unmount hook fires if the component isn't actually being unmounted.
Option 2: Use Inertia's router events
Inertia exposes router events that do fire on every navigation, including navigations that preserve the current component. The one you want is router.on('before', ...) for cleanup before leaving, or router.on('navigate', ...) for reacting after arrival.
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
let pollInterval = null
let removeBeforeListener = null
function startPolling() {
pollInterval = setInterval(() => {
// ... your polling logic
}, 5000)
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
onMounted(() => {
startPolling()
// Listen for Inertia navigations, not just Vue unmounts
removeBeforeListener = router.on('before', () => {
stopPolling()
})
})
onUnmounted(() => {
stopPolling()
removeBeforeListener?.()
})
</script>
The router.on('before', ...) returns a function you call to remove the listener. The onUnmounted then cleans up the listener itself, for the case where the component does actually unmount (e.g., logout).
You still need the interval started in onMounted if you want it running on the initial page load. And you want the cleanup in the Inertia event so it runs on navigation whether or not Vue unmounts.
Option 3: usePoll or extracted composable
If you find yourself doing this a lot, extract it into a composable:
// composables/usePollingOnPage.js
import { onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
export function usePollingOnPage(callback, interval = 5000) {
let timer = null
let removeListener = null
const start = () => {
callback()
timer = setInterval(callback, interval)
}
const stop = () => {
if (timer) {
clearInterval(timer)
timer = null
}
}
onMounted(() => {
start()
removeListener = router.on('before', stop)
})
onUnmounted(() => {
stop()
removeListener?.()
})
return { start, stop }
}
Then in your page component:
<script setup>
import { usePollingOnPage } from '@/composables/usePollingOnPage'
usePollingOnPage(() => {
// fetch latest data
}, 10000)
</script>
Inertia also ships usePoll out of the box in recent versions, which handles most of this for you. Check your Inertia version — if you have it, use it.
A more general lesson
The underlying point goes beyond polling. Any time you're doing something in onMounted that needs a matching cleanup — event listeners, subscriptions, WebSocket connections, keyboard shortcuts, requestAnimationFrame loops — you need to think about two different lifecycles in Inertia:
- The Vue component lifecycle (
onMounted/onUnmounted) - The Inertia page lifecycle (
router.on('before'),router.on('navigate'))
In a pure Vue SPA, these are the same thing. In Inertia, they're not. Treat them as separate concerns and bind cleanup to the right one.
Debugging tip
If you're not sure whether a component is being reused across navigations, drop a console.log in onMounted and watch the console as you navigate. If you expect two mount events (leaving one page, entering another) but only see one, you've confirmed the instance is being reused — and any cleanup you have in onUnmounted is not running.
Vue DevTools can also show you the component tree before and after navigation. If the same instance ID is still there, it wasn't unmounted.
The takeaway
Inertia's page preservation is a feature, not a bug. It makes the app feel fast and avoids unnecessary DOM thrashing. But it changes the assumption most Vue tutorials build on — that navigating "away" from a component always triggers onUnmounted.
When you need cleanup that runs on every navigation, bind it to Inertia's router events, not Vue's lifecycle hooks. Or, even better, use a composable that handles both and forget about it.