Seamless, multi-tab session orchestration for the modern web.
IdleSession is a high-performance, dependency-free ES Module for managing session lifecycles in modern web applications. It eliminates the "timer wars" common in multi-tab environments and is a native-first successor to jquery-idletimer.
Try it in your browser: thorst.github.io/idle-session
- Synchronized Multi-Tab State — Uses the native
BroadcastChannelAPI to ensure activity in any open tab resets the session timer globally across all instances. - Native-First Architecture — Zero dependencies, zero jQuery, zero bloat. Built for 2026 standards.
- Performance-Optimized — Passive, throttled event listeners ensure zero UI jank and zero timer spam during high-frequency events like
mousemove. - Resilient Heartbeats — Heartbeats only fire when the user is active. Transient network failures are swallowed; only
401/403responses trigger logout. - Session Warning Dialog — Automatically injects an accessible
<dialog>element before timeout (configurable viawarningBefore), letting users extend their session. - Network Awareness — Differentiates between transient network failures and hard authorization failures (
401/403).
npm install idle-sessionOr via CDN (no install needed):
<script type="module">
import { IdleSession } from 'https://cdn.jsdelivr.net/npm/idle-session/IdleSession.js';
</script>import { IdleSession } from 'idle-session';
const session = new IdleSession({
timeout: 10 * 60 * 1000, // 10 minutes
onHeartbeat: async () => {
try {
const res = await fetch('/api/keep-alive', { method: 'POST' });
if (res.status === 401 || res.status === 403) throw new Error('Unauthorized');
} catch (err) {
if (err.message === 'Unauthorized') throw err;
// Swallow network errors — session stays active, retries next interval.
}
},
onLogout: () => window.location.href = '/login?reason=session_expired'
});All options are optional. Pass any combination to the constructor to override the defaults.
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number |
900000 (15m) |
Idle time in ms before onLogout is triggered. |
heartbeatInterval |
number |
300000 (5m) |
How often (ms) to ping the server when activity is detected. |
warningBefore |
number |
60000 (1m) |
How many ms before timeout to show the warning. Set to 0 to disable. |
channelName |
string |
'session_sync' |
BroadcastChannel name for cross-tab sync. Override if you run multiple independent apps on the same origin. |
onHeartbeat |
async function |
POSTs to /api/keep-alive |
Custom async function to ping your backend. Throwing triggers logout. |
onLogout |
function |
Redirects to /logout |
Callback executed when the session expires or is forcibly revoked. |
onWarning |
function |
Built-in <dialog> |
Called instead of the built-in warning modal when the session is about to expire. Receives { extend, logout } — call extend() to reset the session, logout() to force-end it. If omitted, the default styled <dialog> is used. |
Use the defaults for zero-config protection:
const session = new IdleSession();Override onHeartbeat to use a custom HTTP client or send auth headers:
const session = new IdleSession({
onHeartbeat: async () => {
const response = await apiClient.post('/auth/keep-alive', {
sessionID: localStorage.getItem('sid')
});
// Throwing here triggers automatic logout across all tabs
if (response.status !== 200) throw new Error('Session terminated by server');
}
});Override onLogout to run cleanup before redirecting:
const session = new IdleSession({
onLogout: async () => {
await analytics.track('session_expired');
localStorage.clear();
window.location.href = '/login?reason=timeout';
}
});Provide onWarning to replace the built-in dialog with your own UI. The callback receives { extend, logout }:
const session = new IdleSession({
onWarning: ({ extend, logout }) => {
// Show your own modal, toast, banner — anything.
myModal.open({
onStayLoggedIn: extend, // resets the idle timer
onLogOut: logout, // ends the session immediately
});
}
});The default built-in dialog is used only when onWarning is not provided.
If your app already uses Bootstrap, pass onWarning and wire it to a Bootstrap modal:
<!-- In your HTML -->
<div class="modal fade" id="sessionModal" tabindex="-1" aria-labelledby="sessionModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sessionModalLabel">Session Expiring</h5>
</div>
<div class="modal-body">
Your session is about to expire due to inactivity.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="sessionLogout">Log Out</button>
<button type="button" class="btn btn-primary" id="sessionExtend">Stay Logged In</button>
</div>
</div>
</div>
</div>const modalEl = document.getElementById('sessionModal');
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
document.getElementById('sessionExtend').addEventListener('click', () => bsModal.hide());
document.getElementById('sessionLogout').addEventListener('click', () => bsModal.hide());
const session = new IdleSession({
onWarning: ({ extend, logout }) => {
document.getElementById('sessionExtend').onclick = () => { extend(); bsModal.hide(); };
document.getElementById('sessionLogout').onclick = () => { logout(); bsModal.hide(); };
bsModal.show();
}
});If you use Tailwind CSS, you can build a modal inline and toggle it via a flag. This example uses Alpine.js, a common lightweight companion to Tailwind:
<!-- In your HTML -->
<div x-data="sessionWarning()" x-show="open" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-2xl shadow-xl p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-semibold text-gray-900">Session Expiring</h2>
<p class="mt-2 text-sm text-gray-500">Your session is about to expire due to inactivity.</p>
<div class="mt-6 flex justify-end gap-3">
<button @click="doLogout()" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">Log Out</button>
<button @click="doExtend()" class="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">Stay Logged In</button>
</div>
</div>
</div>// Alpine.js component — exposes open/extend/logout to the template above
function sessionWarning() {
return {
open: false,
_extend: null,
_logout: null,
doExtend() { this.open = false; this._extend?.(); },
doLogout() { this.open = false; this._logout?.(); },
show({ extend, logout }) {
this._extend = extend;
this._logout = logout;
this.open = true;
}
};
}
// Grab the Alpine component instance after Alpine has initialized
document.addEventListener('alpine:initialized', () => {
const warningComponent = Alpine.$data(document.querySelector('[x-data="sessionWarning()"]'));
const session = new IdleSession({
onWarning: ({ extend, logout }) => warningComponent.show({ extend, logout })
});
});If you omit onWarning, the default <dialog> can be themed without touching the library — just set CSS custom properties anywhere in your stylesheet:
/* Example: match a dark application theme */
:root {
--idle-bg: #1c1f26;
--idle-color: #e8eaf0;
--idle-heading: #f59e0b;
--idle-muted: #9ca3af;
--idle-border: #2e3340;
--idle-accent: #6366f1;
--idle-accent-text: #ffffff;
}| Property | Controls | Default |
|---|---|---|
--idle-bg |
Dialog background | #ffffff |
--idle-color |
Body text | #111827 |
--idle-heading |
Heading color | inherits --idle-color |
--idle-muted |
Subtext and secondary button | #6b7280 |
--idle-border |
Dialog border and button borders | #e5e7eb |
--idle-accent |
Primary button background | #2563eb |
--idle-accent-text |
Primary button text | #ffffff |
Call destroy() to remove all event listeners, clear timers, close the BroadcastChannel, and remove any open warning dialog. Essential when unmounting the session in a single-page application:
// React / Vue / etc.
onUnmount(() => session.destroy());
// Or when reinitializing with new config
session.destroy();
session = new IdleSession({ timeout: newTimeout });The module treats the network as unreliable. Transient network failures inside onHeartbeat should be caught and swallowed — only throw on hard auth failures (401/403) so that connectivity blips do not end the session. The default onHeartbeat already does this. If you provide a custom onHeartbeat, follow the same pattern:
onHeartbeat: async () => {
try {
const res = await fetch('/api/keep-alive', { method: 'POST' });
if (res.status === 401 || res.status === 403) throw new Error('Unauthorized');
} catch (err) {
if (err.message === 'Unauthorized') throw err;
// Network errors are swallowed — session stays active, retries next interval.
}
}A 401 or 403 response is treated as a hard termination signal, triggering an immediate and synchronized logout across all open tabs.
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
BroadcastChannel |
v54+ | v38+ | v15.4+ | v79+ |
<dialog> element |
v37+ | v98+ | v15.4+ | v79+ |
This library uses a two-tier testing strategy.
After cloning the repository, run the following before any test or coverage commands:
npm install # install dev dependencies
npx playwright install # download Playwright browser binaries (required once per machine)To verify the dev server runs correctly before testing:
npm run dev # starts Vite at http://localhost:5173 — open in a browser to confirmRuns all core logic through every branch — including error handling and multi-tab state synchronization — using Playwright.
npm testFor a detailed coverage report:
npm run coverage
# View the generated coverage/ folderDesigned to run against a staging or pre-production environment. Confirms the module initializes correctly, that your Content Security Policy permits BroadcastChannel and fetch, and that there are no race conditions with your DOM.
npx playwright test tests/smoke.spec.jsMIT