Skip to content

Session Timeout

This is the thing every large organisation builds itself, and gets wrong. Either no warning comes, and the user loses a half-completed application form. Or the dialog steals focus in the middle of a sentence. Or the screen reader reads the countdown every single second.

You write the dialog and the text yourself, in the language the app uses. The component tracks activity, opens the dialog, fills in the number and announces the countdown.

// main.ts, runs once when the app starts
import { defineLtSessionTimeout } from "@lo-tek/interfaces/session-timeout"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/session-timeout.css"
defineLtSessionTimeout()

The text in the warning belongs to the app. It should be in the language the app uses, with the words the app uses about logging in, and come from the translation setup the app already has. If the text were in the component, it could not be translated at all. See Translation.

The component finds the parts by what has to be there anyway:

PartRecognised by
The dialogThe first <dialog> inside the element
The number.lt-session-timeout__count. Is empty, and the component fills it in
The announcement[role=status]. Is empty, and the component fills it in
The buttonsButtons in a <form method="dialog">

The buttons are ordinary buttons. The browser closes the dialog and sets returnValue to the button’s value, without script. value="logout" logs out. Everything else, Escape and the button with value="extend" included, means the user wants to continue.

If the class lt-session-timeout__dialog, role="alertdialog" or a name on the dialog is missing, the component fills them in. The name comes from the first heading, which gets an id if it has none. What the renderer wrote stays.

The countdown is the client’s clock, and changes depending on when the user last touched the keyboard. That is why the builder function sets data-ignore-morph, so a patch from the server leaves the warning alone. If a patch still removes open from a dialog that is in the top layer, the component puts it back. See Markup and behaviour.

AttributeMeaningDefault
warn-atSeconds without activity before the warning appears1500 (25 minutes)
expires-atSeconds without activity before the session is over1800 (30 minutes)
activity-intervalSeconds between each session-activity while the user is active. At least 160

Activity is keystrokes, clicks and scrolling, also scrolling in a table or a panel. Scrolling also counts when a screen reader in browse mode scrolls the page, since it does not send keystrokes to the page. This means that scrolling done by code, like a log that follows along, also keeps the clock going. While the dialog is open, activity does not count, otherwise a random mouse click would extend the session without the user meaning to.

If warn-at or expires-at is not a number greater than or equal to zero, the default is used, and if warn-at is not less than expires-at, the warning never appears. The component warns about both in the console.

After session-expired and session-logout the component stands still until extend() or reset() is called. Without that, an app that did not navigate away got a new dialog and a new session-expired every expires-at seconds, for a session that was already gone.

EventWhenWhat you do
session-warnThe dialog opensUsually nothing
session-extendThe user chooses to continue, with the button or EscapeAsk the server to extend the session
session-logoutThe user chooses to log outSend the user to the logout
session-expiredThe time is upSend the user to the login
session-activityThe user is active, at most once per activity-intervalAsk the server to keep the session alive

The component does not know how your session is extended. It tells you, you make the call.

Listen for session-activity if the server session is only extended by calls to the server. The clock here is reset by activity in the browser. A user typing in a long form without a single call therefore never gets the warning, while the server session expires, and the submission fails. The event comes at the start of an interval and at the end if the user has done something since, so the server is never more than one interval behind.

Several tabs do not share a clock. Each tab counts its own activity, so a tab the user is not using shows the warning and sends session-expired while the user is working in another. If the tabs should follow each other, let them tell each other and call reset(), both when the user is active and when the user chooses to continue in the warning. A tab that is already logged out should not be started again:

const channel = new BroadcastChannel("session")
let loggedOut = false
for (const name of ["session-activity", "session-extend"]) {
session.addEventListener(name, () => channel.postMessage("active"))
}
for (const name of ["session-expired", "session-logout"]) {
session.addEventListener(name, () => (loggedOut = true))
}
channel.addEventListener("message", () => {
if (!loggedOut) session.reset()
})
MethodWhat it does
extend()Resets the clock, closes the warning and sends session-extend
reset()Resets the clock and closes the warning without sending anything. For an app that has already extended the session itself, as when an autosave went through

extendSession(element) and resetSession(element) from @lo-tek/interfaces/session-timeout do the same, but first wait for the element to be registered.

ClassWhat it is
.lt-session-timeoutThe host element. Is display: contents and takes no space
.lt-session-timeout__dialogThe dialog itself
.lt-session-timeout__titleThe heading
.lt-session-timeout__textThe paragraph with the countdown in it
.lt-session-timeout__countThe number that counts down
.lt-session-timeout__actionsThe form with the buttons

Shape and size are read from component variables, with the system’s value as the fallback:

VariableFallback
--lt-session-timeout-width26rem
--lt-session-timeout-paddingvar(--lt-spacing-5)
--lt-session-timeout-radiusvar(--lt-spacing-1)
.lt-session-timeout__dialog {
--lt-session-timeout-width: 32rem;
}

The dialog is a real <dialog> opened with showModal(), so the focus trap, Escape and the top layer come from the browser. It has role="alertdialog", because it interrupts something the user did not ask to be interrupted in.

Three things are done on purpose, and all three are reasons this is hard to get right yourself:

  • Focus returns to where the user was. Without that, the next keystroke starts at the top of the page, in the middle of a form.
  • The countdown is not announced every second. The number on the screen has aria-hidden, and the announcement speaks at two minutes, one minute, 30 seconds and 10 seconds. An announcement every second makes the dialog unusable.
  • The announcement is in the page’s language. It is your paragraph, with the number replaced by a spoken duration. The duration is written with the browser’s Intl from the lang of the page, so it says “1 minute and 30 seconds” on an English page and “1 minutt og 30 sekunder” on a Norwegian one.

Put the button that extends first. It is the one most people want, and it should be the first thing the keyboard reaches.