Node.jsの開発において、非同期処理の制御は避けて通れない重要なテーマの一つです。

特に「一定時間後に処理を実行する」という単純な操作を支えるsetTimeoutは、基礎的な機能でありながら、モダンなアプリケーション開発においてはより洗練された使い方が求められています。

近年のNode.jsでは、PromiseベースのタイマーAPIやAbortSignalを用いたキャンセル処理が標準化され、従来のコールバック形式よりも安全で読みやすいコードが記述可能になりました。

本記事では、Node.jsにおけるsetTimeoutの進化を辿りながら、最新のベストプラクティスについて詳しく解説します。

Node.jsにおけるsetTimeoutの基本と進化

Node.jsにおけるsetTimeoutは、指定したミリ秒が経過した後に、登録されたコールバック関数をイベントループのタイミングで実行する仕組みです。

ブラウザ環境のsetTimeoutと似ていますが、Node.jsの実装はlibuvによるイベントループと密接に関わっており、返り値が数値(ID)ではなくオブジェクトである点などの違いがあります。

従来のコールバック形式による実装

最も歴史が古く、馴染みのある使い方は、第一引数に関数を渡し、第二引数に待機時間を指定する形式です。

JavaScript
// 従来の基本的なsetTimeoutの使い方
setTimeout(() => {
    console.log("3秒が経過しました");
}, 3000);

console.log("タイマーを開始しました");
実行結果
タイマーを開始しました
3秒が経過しました

この形式はシンプルですが、複数の非同期処理を連続して行う場合に「コールバック地獄」に陥りやすいという課題がありました。

また、エラーハンドリングをtry-catchで行うことができず、コードの可読性や保守性を低下させる要因となっていました。

モダンなPromiseベースのsetTimeout

Node.js v15以降では、標準モジュールとしてnode:timers/promisesが提供されています。

これにより、手動でnew Promise()を作成することなく、ネイティブでasync/awaitを利用した待機処理が可能になりました。

JavaScript
import { setTimeout } from 'node:timers/promises';

async function executeTask() {
    console.log("処理を開始します");
    
    // PromiseベースのsetTimeoutで1秒待機
    await setTimeout(1000);
    
    console.log("1秒待機した後に実行されました");
}

executeTask();

このアプローチを採用することで、コードのネストが解消され、逐次的な処理の流れを直感的に記述できるようになります。

AbortSignalによるタイムアウト制御の重要性

モダンなNode.jsアプリケーションにおいて、setTimeoutと組み合わせて語る上で欠かせないのがAbortControllerとAbortSignalです。

従来のsetTimeoutでは、設定したタイマーを途中でキャンセルするためにclearTimeoutを呼び出す必要がありました。

しかし、複数の非同期タスクが絡み合う複雑なロジックでは、どのタイマーをどこで管理すべきかが不透明になりがちです。

AbortControllerの基本概念

AbortControllerは、一つまたは複数の非同期操作を中断するための標準的なインターフェースを提供します。

controller.signalを各関数に渡すことで、外部から一斉に処理の停止を命じることができます。

Node.jsのnode:timers/promisessetTimeoutは、このAbortSignalをオプション引数として受け取ることが可能です。

AbortSignalを使用したタイマーのキャンセル

以下の例では、特定の条件が発生した際にタイマーを中断する方法を示します。

JavaScript
import { setTimeout } from 'node:timers/promises';

async function performCancellableTask() {
    const ac = new AbortController();
    const signal = ac.signal;

    // 500ミリ秒後にキャンセルを実行する
    setTimeout(500).then(() => ac.abort());

    try {
        console.log("1秒のタイマーを開始します...");
        // 1秒待とうとするが、途中でsignalにより中断される
        await setTimeout(1000, 'result', { signal });
        console.log("この行は実行されません");
    } catch (err) {
        if (err.name === 'AbortError') {
            console.error("タイマーが正常にキャンセルされました");
        } else {
            throw err;
        }
    }
}

performCancellableTask();

このように、AbortSignalを利用することで、タイムアウト処理自体を外部から構造的に制御できるようになります。

これは、APIリクエストのタイムアウト管理や、ユーザーによるブラウザ操作の中断など、実務的なシーンで非常に強力な武器となります。

setTimeoutのPromise化におけるベストプラクティス

標準のnode:timers/promisesを使えない古い環境や、独自の拡張を行いたい場合には、手動でPromise化(Promisify)を行うことがあります。

しかし、単にラップするだけでは不十分であり、リソースの解放やキャンセルへの対応を考慮する必要があります。

手動でのPromise化と注意点

もっともシンプルなPromise化は以下のようになりますが、これには欠点があります。

JavaScript
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

この実装の問題点は、一度開始すると途中で止める手段がないことです。

より堅牢な実装にするためには、前述のAbortSignalを考慮した設計にすべきです。

堅牢なPromiseベース・タイマーの実装例

プロダクション環境でも利用可能な、キャンセル対応型のタイマー関数を考えてみましょう。

JavaScript
function enhancedDelay(ms, { signal } = {}) {
    return new Promise((resolve, reject) => {
        if (signal?.aborted) {
            return reject(new Error('既にキャンセルされています'));
        }

        const timer = setTimeout(() => {
            resolve();
            signal?.removeEventListener('abort', abortHandler);
        }, ms);

        const abortHandler = () => {
            clearTimeout(timer);
            reject(new Error('タイムアウトがキャンセルされました'));
        };

        signal?.addEventListener('abort', abortHandler);
    });
}

このコードでは、setTimeoutによるメモリリークを防ぐために、処理完了時にイベントリスナーを適切に削除しています。

また、実行前に既にキャンセルされていないかを確認するガード節を設けることで、パフォーマンスと安全性を両立させています。

Node.js特有のタイマー制御機能:unrefとref

Node.jsのsetTimeoutが返すオブジェクトには、ブラウザにはない独自のメソッドであるunref()ref()が存在します。

これらは、Node.jsのプロセスが終了するかどうかを制御するための強力な機能です。

unref()の活用シーン

通常、アクティブなタイマーが存在する場合、Node.jsのイベントループはプロセスを終了させません。

しかし、「補助的なタスクなので、メインの処理が終わったらタイマーが残っていても終了してほしい」というケースがあります。

JavaScript
const timer = setTimeout(() => {
    console.log("この処理はプロセスを維持しません");
}, 10000);

// プロセス内で他に実行すべきことがなければ、10秒待たずに終了する
timer.unref();

ログの収集や定期的なメトリクスの送信など、バックグラウンドでのみ動作させたいタイマーにはunref()を適用するのがベストプラクティスです。

ref()による維持

逆に、一度unref()したタイマーに対して、再びプロセスを維持するように命じるのがref()です。

動的な条件によってプロセスの生存期間を調整する必要がある場合に利用されます。

パフォーマンスへの影響とイベントループの理解

setTimeoutの精度は、Node.jsのイベントループの負荷に依存します。

指定した時間は「最低でもこれだけ待つ」という保証であり、正確にそのミリ秒後に実行されるわけではありません。

タイマー精度に影響を与える要因

以下の表は、setTimeoutの実行遅延を引き起こす主な要因をまとめたものです。

要因詳細対策
CPU集約的な処理メインスレッドが計算で占有されていると、タイマーのチェックが遅れます。重い処理はWorker Threadsへ逃がす。
ガベージコレクション大規模なメモリ解放中はイベントループが一時停止することがあります。メモリ割り当てを最適化し、GCの頻度を下げる。
OSのスケジューリングシステム全体の負荷が高い場合、スレッドの割り当てが遅れます。実行環境のCPUリソースに余裕を持たせる。

特に「0ミリ秒」を指定した場合でも、実際には1ミリ秒程度の最小遅延が発生する点に注意が必要です。

即座に実行したい場合は、setImmediateを使用する方がNode.jsの設計意図に適しています。

実務でのユースケース:APIリクエストのタイムアウト実装

実際の開発現場で最も多いのが、ネットワークリクエストにタイムアウト制限を設けるケースです。

fetchaxiosなどのライブラリとAbortSignal.timeout()を組み合わせることで、非常に簡潔に記述できます。

AbortSignal.timeout()による簡略化

Node.js v18以降、特定の時間で自動的に発火するAbortSignalを生成するAbortSignal.timeout()が導入されました。

JavaScript
async function fetchWithTimeout(url, ms) {
    try {
        const response = await fetch(url, {
            // 指定時間で自動的にキャンセルされるシグナルを渡す
            signal: AbortSignal.timeout(ms)
        });
        return await response.json();
    } catch (err) {
        if (err.name === 'TimeoutError') {
            console.error("リクエストがタイムアウトしました");
        }
        throw err;
    }
}

この手法は、自前でsetTimeoutを管理する手間を完全に排除できるため、現代のNode.js開発における標準的なアプローチとなっています。

まとめ

Node.jsにおけるsetTimeoutは、単なる待機関数から、PromiseやAbortSignalと連携する洗練された非同期制御ツールへと進化を遂げました。

モダンな環境では、従来のコールバック形式を避け、node:timers/promisesを活用することで可読性と安全性を向上させることができます。

また、AbortControllerを用いたキャンセル処理の導入は、リソースの無駄遣いを防ぎ、予期せぬ動作を抑制するために不可欠なスキルです。

最後に、unref()ref()といったNode.js特有の機能を理解することで、システムの挙動をより精密にコントロールできるようになります。

これらのテクニックを適切に使い分け、効率的で堅牢なNode.jsアプリケーションを構築していきましょう。