【JavaScript】一定時間操作がなかった場合に処理を実行する方法|アイドル検出・自動ログアウト・セッションタイムアウト・visibilitychange 対応まで解説

「5分間操作がなければ自動ログアウトする」「30秒間入力がなければ下書き保存する」——こうしたアイドル検出は、セキュリティや UX の観点から実務でよく求められる機能です。

基本的な仕組みは setTimeoutユーザー操作イベントのたびにタイマーをリセットする、というシンプルなパターンです。この記事では基本実装から、カウントダウン表示・タブ非表示中の正確な計測・再利用しやすいクラス設計まで段階的に解説します。

スポンサーリンク

アイドル検出の仕組み

アイドル検出は次の3ステップで動作します。

  1. タイムアウト時間を setTimeout でセットする
  2. ユーザーが操作するたびに clearTimeout してタイマーをリセットする
  3. リセットされないまま指定時間が経過すると、コールバックが実行される
アイドル検出の基本実装
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 } を付ける理由:scrolltouchstarttouchmovepassive: 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段階のタイマーで構成します。

  1. 警告タイマー:アイドル4分でダイアログを表示
  2. ログアウトタイマー:警告表示から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秒に制限します。長時間放置の場合はさらに遅延が大きくなることがあります。visibilitychangeDate.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 のタイマーは正確ですか?
AsetTimeout は指定時間が経過した、JavaScriptのイベントループが空いたときに実行されるため、厳密には指定時間より少し遅くなります。また、タブが非アクティブのときはブラウザによって最低1秒以上に制限される場合があります。秒単位の精度が必要な場合は Date.now() で実際の経過時間を確認してください。
QSPA(シングルページアプリ)でルート遷移時にタイマーをリセットするには?
Aページ遷移ごとに timer.stop() を呼んでから timer.start() し直すか、ルーティングイベント(React Router の useEffect など)で resetIdleTimer() を呼び出してください。タイマーを停止しないままルート遷移すると、前のページのコールバックが実行されることがあります。
Qサーバー側でセッションタイムアウトを管理している場合、フロントのタイマーと合わせる必要がありますか?
Aはい、整合性を合わせることを推奨します。クライアント側のタイマーはあくまでUXのための警告で、実際のセキュリティはサーバー側のセッション管理が担います。クライアント側のタイムアウト時間をサーバー側より短く設定しておくと、サーバーのセッション切れより先にユーザーに通知できます。
Qモバイルでスリープ(画面オフ)中もタイマーは動きますか?
Aデバイスがスリープするとブラウザのタイマーは停止・大幅に遅延します。visibilitychange イベントで復帰時に経過時間を計算する方法が有効ですが、完全に停止した場合は visibilitychange すら発火しないことがあります。厳密なセキュリティが必要な場合は、APIリクエスト時にサーバー側でセッションの有効期限を確認することを推奨します。
Qイベントリスナーのメモリリークを防ぐにはどうすればいいですか?
AIdleTimer クラスの stop() メソッドのように、removeEventListener で登録したリスナーを削除してください。addEventListener に渡す関数は同じ参照でないと削除できないため、クラスのコンストラクタで this.#reset = this.#reset.bind(this) のように参照を固定しておくことが重要です。

まとめ

一定時間操作がなかった場合の処理実装のポイントをまとめます。

  • 基本は setTimeout + ユーザー操作で clearTimeout してリセット
  • mousemove など頻繁なイベントはスロットリングして無駄な処理を減らす
  • 自動ログアウトは「警告→カウントダウン→ログアウト」の2段階構成が UX に優れる
  • タブ非表示中の正確な計測は visibilitychange + Date.now() で対応
  • 再利用には IdleTimer クラスにまとめ、stop() でリスナーを必ず削除する
  • 自動保存など「特定フィールドの入力停止」にはデバウンスが適切

setInterval と setTimeout の詳しい使い方は【JavaScript】リアルタイム時計の作り方完全ガイド、改行コードの変換については【JavaScript】改行を <br> タグに変換する方法もあわせてご覧ください。