「5分間操作がなければ自動ログアウトする」「30秒間入力がなければ下書き保存する」——こうしたアイドル検出は、セキュリティや UX の観点から実務でよく求められる機能です。
基本的な仕組みは setTimeout と ユーザー操作イベントのたびにタイマーをリセットする、というシンプルなパターンです。この記事では基本実装から、カウントダウン表示・タブ非表示中の正確な計測・再利用しやすいクラス設計まで段階的に解説します。
アイドル検出の仕組み
アイドル検出は次の3ステップで動作します。
- タイムアウト時間を
setTimeoutでセットする - ユーザーが操作するたびに
clearTimeoutしてタイマーをリセットする - リセットされないまま指定時間が経過すると、コールバックが実行される
アイドル検出の基本実装
const IDLE_MS = 5 * 60 * 1000; // 5分(ミリ秒)
let idleTimer = null;
function resetTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(onIdle, IDLE_MS);
}
function onIdle() {
console.log('5分間操作がありませんでした');
// → ログアウト処理・警告表示など
}
// 監視するイベント一覧
const WATCH_EVENTS = [
'mousemove', 'mousedown', 'keydown',
'touchstart', 'touchmove', 'scroll', 'click',
];
WATCH_EVENTS.forEach((event) => {
document.addEventListener(event, resetTimer, { passive: true });
});
// 初回スタート
resetTimer();
{ passive: true } を付ける理由:
scroll・touchstart・touchmove に passive: true を付けると、ブラウザはスクロールパフォーマンスを最適化できます。今回はタイマーリセットのみでスクロールを止めないため、passive: true が適切です。監視するイベントの選び方
| イベント | 用途 | ポイント |
|---|---|---|
mousemove |
マウス移動 | 頻繁に発火するためthrottleと組み合わせると効率的 |
mousedown |
マウスクリック押下 | clickより早く検知 |
keydown |
キー入力 | テキスト入力・ショートカット操作を検知 |
touchstart |
タッチ開始 | スマートフォン対応に必須 |
scroll |
スクロール | ページを読んでいる操作を検知 |
visibilitychange |
タブ切り替え | 別タブに移動したときの処理に使う(後述) |
mousemove は発火頻度が高い:
mousemove はマウスが1ピクセル動くたびに発火するため、resetTimer が秒間数十回呼ばれます。setTimeout の呼び出し自体は軽量ですが、重い処理を resetTimer 内で行う場合はスロットリングを検討してください。スロットリングでイベント過多を防ぐ
mousemove など頻繁に発火するイベントがある場合、一定間隔(例:1秒)に間引くと無駄な処理を抑えられます。
throttle を使ったリセット最適化
function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
// 1秒に1回だけ resetTimer を呼ぶ
const throttledReset = throttle(resetTimer, 1000);
WATCH_EVENTS.forEach((event) => {
document.addEventListener(event, throttledReset, { passive: true });
});
カウントダウン表示つき自動ログアウトの実装
多くのシステムで使われる「あと N 秒でログアウトします」という警告 UI の実装です。2段階のタイマーで構成します。
- 警告タイマー:アイドル4分でダイアログを表示
- ログアウトタイマー:警告表示から60秒後に実際にログアウト
HTML(警告ダイアログ)
<div id="idleWarning" class="idle-dialog" hidden aria-live="assertive" role="alertdialog"
aria-labelledby="idle-title" aria-describedby="idle-desc">
<p id="idle-title"><strong>セッションタイムアウトの警告</strong></p>
<p id="idle-desc">操作がない状態が続いています。<br>
<span id="countdownSec">60</span> 秒後に自動的にログアウトします。</p>
<button id="stayBtn" type="button">ログイン状態を維持する</button>
</div>
CSS(警告ダイアログ)
.idle-dialog {
position: fixed;
bottom: 24px;
right: 24px;
padding: 20px 24px;
background: #fff;
border: 2px solid #f59e0b;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
max-width: 320px;
z-index: 9999;
}
.idle-dialog button {
margin-top: 12px;
padding: 8px 18px;
background: #0284c7;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
2段階タイマーによる自動ログアウト実装
const WARN_AFTER = 4 * 60 * 1000; // 4分後に警告
const LOGOUT_AFTER = 60 * 1000; // 警告から60秒後にログアウト
const dialog = document.getElementById('idleWarning');
const countdownEl = document.getElementById('countdownSec');
const stayBtn = document.getElementById('stayBtn');
let warnTimer = null;
let logoutTimer = null;
let countdownInterval = null;
function showWarning() {
dialog.hidden = false;
let remaining = LOGOUT_AFTER / 1000;
countdownEl.textContent = remaining;
// カウントダウン表示
countdownInterval = setInterval(() => {
remaining--;
countdownEl.textContent = remaining;
if (remaining <= 0) clearInterval(countdownInterval);
}, 1000);
logoutTimer = setTimeout(logout, LOGOUT_AFTER);
}
function hideWarning() {
dialog.hidden = true;
clearTimeout(logoutTimer);
clearInterval(countdownInterval);
}
function logout() {
hideWarning();
// 実際のログアウト処理(例: ページ遷移)
window.location.href = '/logout';
}
function resetIdleTimer() {
clearTimeout(warnTimer);
hideWarning();
warnTimer = setTimeout(showWarning, WARN_AFTER);
}
// 「ログイン状態を維持する」ボタン
stayBtn.addEventListener('click', resetIdleTimer);
// ユーザー操作の監視
const WATCH_EVENTS = ['mousemove','mousedown','keydown','touchstart','scroll','click'];
WATCH_EVENTS.forEach((ev) => {
document.addEventListener(ev, resetIdleTimer, { passive: true });
});
resetIdleTimer(); // スタート
タブ非表示中の正確な時間計測(Page Visibility API)
setTimeout はタブが非表示になると実行が遅延する場合があります(ブラウザの省電力最適化)。「タブを5分放置してから戻ったら即ログアウト」を正確に実現するには、Page Visibility API で非表示になった時刻を記録し、復帰時に経過時間を計算します。
Page Visibility API で正確な経過時間を計測
let hiddenAt = null; // タブが非表示になった時刻
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// タブが非表示になった
hiddenAt = Date.now();
clearTimeout(warnTimer); // タイマーを止める(省電力)
} else {
// タブが再表示された
if (hiddenAt !== null) {
const elapsed = Date.now() - hiddenAt;
hiddenAt = null;
if (elapsed >= WARN_AFTER) {
// すでに警告時間を超えていたら即ログアウト
logout();
} else {
// 残り時間でタイマーを再セット
clearTimeout(warnTimer);
warnTimer = setTimeout(showWarning, WARN_AFTER - elapsed);
}
}
}
});
setTimeout のタブ非表示中の遅延について:Chrome・Firefox などは非アクティブなタブの
setTimeout の最小間隔を約1秒に制限します。長時間放置の場合はさらに遅延が大きくなることがあります。visibilitychange と Date.now() の組み合わせで実際の経過時間を確認するのが確実です。再利用しやすい IdleTimer クラス
複数のページで使い回せるよう、アイドル検出機能をクラスにまとめます。
IdleTimer クラス
class IdleTimer {
#timeout;
#onIdle;
#onResume;
#timer = null;
#isIdle = false;
#hiddenAt = null;
/**
* @param {object} options
* @param {number} options.timeout - アイドル判定時間(ms)
* @param {Function} options.onIdle - アイドル時のコールバック
* @param {Function} [options.onResume] - 復帰時のコールバック
*/
constructor({ timeout, onIdle, onResume = () => {} }) {
this.#timeout = timeout;
this.#onIdle = onIdle;
this.#onResume = onResume;
this.#reset = this.#reset.bind(this);
this.#handleVisibility = this.#handleVisibility.bind(this);
}
start() {
const events = ['mousemove','mousedown','keydown','touchstart','scroll','click'];
events.forEach((ev) =>
document.addEventListener(ev, this.#reset, { passive: true })
);
document.addEventListener('visibilitychange', this.#handleVisibility);
this.#scheduleIdle();
return this; // チェーン可能
}
stop() {
clearTimeout(this.#timer);
const events = ['mousemove','mousedown','keydown','touchstart','scroll','click'];
events.forEach((ev) =>
document.removeEventListener(ev, this.#reset)
);
document.removeEventListener('visibilitychange', this.#handleVisibility);
}
get isIdle() { return this.#isIdle; }
#scheduleIdle() {
clearTimeout(this.#timer);
this.#timer = setTimeout(() => {
this.#isIdle = true;
this.#onIdle();
}, this.#timeout);
}
#reset() {
if (this.#isIdle) {
this.#isIdle = false;
this.#onResume();
}
this.#scheduleIdle();
}
#handleVisibility() {
if (document.hidden) {
this.#hiddenAt = Date.now();
clearTimeout(this.#timer);
} else if (this.#hiddenAt !== null) {
const elapsed = Date.now() - this.#hiddenAt;
this.#hiddenAt = null;
if (elapsed >= this.#timeout) {
this.#isIdle = true;
this.#onIdle();
} else {
this.#timer = setTimeout(() => {
this.#isIdle = true;
this.#onIdle();
}, this.#timeout - elapsed);
}
}
}
}
// ───── 使い方 ─────
const timer = new IdleTimer({
timeout: 5 * 60 * 1000,
onIdle: () => console.log('アイドル状態になりました'),
onResume: () => console.log('操作が再開されました'),
}).start();
// 停止するとき
// timer.stop();
実践例:入力停止で自動下書き保存
フォームへの入力が止まって3秒後に自動保存する、よくある UX パターンです。これはデバウンス(最後の操作から N 秒後に実行)で実装します。
入力停止3秒後に自動下書き保存
const textarea = document.getElementById('content');
const statusEl = document.getElementById('saveStatus');
let saveTimer = null;
textarea.addEventListener('input', () => {
clearTimeout(saveTimer);
statusEl.textContent = '編集中...';
// 3秒間入力が止まったら保存
saveTimer = setTimeout(async () => {
statusEl.textContent = '保存中...';
try {
await saveDraft(textarea.value);
statusEl.textContent = '下書きを保存しました';
} catch {
statusEl.textContent = '保存に失敗しました';
}
}, 3000);
});
async function saveDraft(content) {
await fetch('/api/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
}
アイドル検出 vs デバウンスの使い分け:ページ全体の操作を監視して「N分間操作なし」を検知するのがアイドル検出。特定の入力フィールドへの入力停止を検知するのがデバウンスです。自動ログアウトにはアイドル検出、自動保存にはデバウンスを使います。
よくある質問
QsetTimeout のタイマーは正確ですか?
A
setTimeout は指定時間が経過した後、JavaScriptのイベントループが空いたときに実行されるため、厳密には指定時間より少し遅くなります。また、タブが非アクティブのときはブラウザによって最低1秒以上に制限される場合があります。秒単位の精度が必要な場合は Date.now() で実際の経過時間を確認してください。QSPA(シングルページアプリ)でルート遷移時にタイマーをリセットするには?
Aページ遷移ごとに
timer.stop() を呼んでから timer.start() し直すか、ルーティングイベント(React Router の useEffect など)で resetIdleTimer() を呼び出してください。タイマーを停止しないままルート遷移すると、前のページのコールバックが実行されることがあります。Qサーバー側でセッションタイムアウトを管理している場合、フロントのタイマーと合わせる必要がありますか?
Aはい、整合性を合わせることを推奨します。クライアント側のタイマーはあくまでUXのための警告で、実際のセキュリティはサーバー側のセッション管理が担います。クライアント側のタイムアウト時間をサーバー側より短く設定しておくと、サーバーのセッション切れより先にユーザーに通知できます。
Qモバイルでスリープ(画面オフ)中もタイマーは動きますか?
Aデバイスがスリープするとブラウザのタイマーは停止・大幅に遅延します。
visibilitychange イベントで復帰時に経過時間を計算する方法が有効ですが、完全に停止した場合は visibilitychange すら発火しないことがあります。厳密なセキュリティが必要な場合は、APIリクエスト時にサーバー側でセッションの有効期限を確認することを推奨します。Qイベントリスナーのメモリリークを防ぐにはどうすればいいですか?
A
IdleTimer クラスの stop() メソッドのように、removeEventListener で登録したリスナーを削除してください。addEventListener に渡す関数は同じ参照でないと削除できないため、クラスのコンストラクタで this.#reset = this.#reset.bind(this) のように参照を固定しておくことが重要です。まとめ
一定時間操作がなかった場合の処理実装のポイントをまとめます。
- 基本は
setTimeout+ ユーザー操作でclearTimeoutしてリセット mousemoveなど頻繁なイベントはスロットリングして無駄な処理を減らす- 自動ログアウトは「警告→カウントダウン→ログアウト」の2段階構成が UX に優れる
- タブ非表示中の正確な計測は
visibilitychange+Date.now()で対応 - 再利用には
IdleTimerクラスにまとめ、stop()でリスナーを必ず削除する - 自動保存など「特定フィールドの入力停止」にはデバウンスが適切
setInterval と setTimeout の詳しい使い方は【JavaScript】リアルタイム時計の作り方完全ガイド、改行コードの変換については【JavaScript】改行を <br> タグに変換する方法もあわせてご覧ください。