はじめに
対象読者は、Node.js 22 系で動いているアプリケーションを 26 系へ上げるか判断したい開発者です。
Node.js 26 の紹介記事は「Temporal が標準で使える」で終わっているものが多く、上げたときに何が動かなくなるかは書かれていません。実際に同じスクリプトを 22 と 26 の両方で走らせると、増えた機能より先に落ちるコードのほうが目に付きます。落ちるのはどこで、それは事前に検出できるのでしょうか。
同一の検証スクリプトを Node.js 22.22.2 と 26.6.0 の 2 つのランタイムに与えて、差分を実測しました。実行環境は Linux x64(Debian 系コンテナ)、26.6.0 は nodejs.org の公式ビルド を展開して使っています。
実測差分の一覧
| 検証項目 | Node.js 22.22.2 | Node.js 26.6.0 | 移行時の影響 |
|---|---|---|---|
Temporal グローバル |
false(未提供) |
true(既定で有効) |
追加のみ |
Map.prototype.getOrInsert |
undefined |
function |
追加のみ |
Map.prototype.getOrInsertComputed |
undefined |
function |
追加のみ |
Iterator.concat |
undefined |
function |
追加のみ |
require('_stream_readable') |
読み込める(警告なし) | MODULE_NOT_FOUND |
破壊的 |
require('_stream_wrap') |
読み込める(DEP0125 警告) | MODULE_NOT_FOUND |
破壊的 |
res.writeHeader() |
function |
TypeError: res.writeHeader is not a function |
破壊的 |
module.register() |
警告なし | DEP0205 警告つきで動作 | 猶予あり |
| V8 | 12.4.254.21 | 14.6.202.34 | 依存 |
| undici(内蔵 fetch) | 6.24.1 | 8.9.0 | 依存 |
process.versions.modules(ABI) |
127 | 147 | ネイティブ再ビルド必須 |
破壊的な変更は 3 系統(旧ストリームモジュール、writeHeader()、ABI 更新によるネイティブアドオン)に集約されます。残りは足し算なので、既存コードを壊しません。
移行の判断フロー
軸1: Temporal は「Date の置き換え」ではなく「タイムゾーン計算の置き換え」
Node.js 26.0.0 のリリースノートによると、26 系(2026 年 5 月 5 日リリース)は Temporal をフラグなしで有効化しています。22 系では 'Temporal' in globalThis が false を返すため、ポリフィルなしでは触れません。
Temporal の価値がはっきり出るのは、夏時間(DST)の境界をまたぐ計算でした。米国東部が 2026 年 3 月 8 日 02:00 に 1 時間進むケースを、同じ入力で両方の書き方に通した結果です。
# Node.js 22.22.2 / 26.6.0 共通(Date + Intl)
Date.base(NY) 2026-03-08, 01:30:00 EST
Date.plus1h(NY) 2026-03-08, 03:30:00 EDT
Date.plus1d_naive(NY) 2026-03-09, 02:30:00 EDT ← 壁時計が 1 時間ずれる
# Node.js 26.6.0(Temporal)
Temporal.plus1h(NY) 2026-03-08T03:30:00-04:00[America/New_York]
Temporal.plus1d(NY) 2026-03-09T01:30:00-04:00[America/New_York]
Temporal.plus1d_hoursElapsed PT23H
Date で「翌日の同時刻」を出そうとして 24 時間を足すと、DST を挟んだ日は 01:30 が 02:30 になります。Temporal.ZonedDateTime の add({ days: 1 }) は壁時計の 01:30 を維持し、経過時間のほうを 23 時間(PT23H)として返しました。日次バッチの起動時刻やサブスクリプションの更新日を扱うコードでは、この差がそのまま不具合になります。
一方で、UTC のタイムスタンプ同士の差分計算しかしていないコードには、Temporal を入れる理由がありません。移行の動機は「日付ライブラリを剥がせるか」ではなく「タイムゾーンつきの暦計算があるか」で切り分けるのが現実的でした。
軸2: getOrInsert は速度ではなく行数の話
V8 14.6 に載った Map.prototype.getOrInsert / getOrInsertComputed は、if (!map.has(k)) map.set(k, []) の定型を 1 行に畳みます。同じ処理を 1,000 万回まわして、26.6.0 の中で 2 実装を比較しました(3 回実行)。
| 実装 | 1回目 | 2回目 | 3回目 |
|---|---|---|---|
has + set + get
|
877.3 ms | 791.5 ms | 927.1 ms |
getOrInsertComputed |
743.2 ms | 750.6 ms | 744.4 ms |
中央値では 877 ms 対 744 ms で 15% ほど短くなりましたが、従来実装のばらつき(791〜927 ms)が差の大きさに近く、これを性能改善の根拠にするのは無理があります。実際に効いたのは、has と get でキーを二重に引く記述が消えることでした。筆者が書き換えて感じた利点も、ホットパスの短縮ではなくレビュー時の読み取りやすさの側です。
軸3: 壊れる 3 系統の実測
旧ストリームモジュールは即座に落ちる
# Node.js 22.22.2
require(_stream_readable) OK (警告なし)
require(_stream_wrap) OK (DEP0125 DeprecationWarning)
# Node.js 26.6.0
require(_stream_readable) MODULE_NOT_FOUND
require(_stream_wrap) MODULE_NOT_FOUND
22 系ではどちらも読み込めるため、依存パッケージがこれを掴んでいても気づけません。26 系では MODULE_NOT_FOUND になり、require した瞬間に停止します。自分のコードで直接書いていなくても、古い readable-stream の内部実装や、ストリームをラップする古いミドルウェアが対象になります。
警告の出かたは同じ _stream_* でも揃っていませんでした。_stream_wrap は DEP0125 の警告が出る一方、_stream_readable は --pending-deprecation と --throw-deprecation を両方付けても、22.22.2 では無言で読み込めます。非推奨警告を頼りにした事前検出は、ここで穴が空きます。
res.writeHeader() はランタイムエラー
http.ServerResponse.prototype.writeHeader は 22 系では function、26 系では undefined でした。実際に HTTP サーバーを起動して呼ぶと、リクエスト処理の中で例外になります。
# Node.js 22.22.2
writeHeader OK
# Node.js 26.6.0
writeHeader TypeError: res.writeHeader is not a function
置き換え先は res.writeHead() で、引数の互換性はあります。厄介なのは、静的解析では見つけにくい位置(エラーハンドラや条件分岐の奥)に残っていた場合で、そのパスを踏むまで表面化しません。
ネイティブアドオンは ABI 127 から 147 へ
process.versions.modules が 127 から 147 に上がっているため、22 系向けにビルド済みのネイティブアドオンはそのまま読み込めません。npm rebuild や prebuild 済みバイナリの入れ替えが必要です。ここは JavaScript 側の修正では回避できない、いちばん時間の読めない部分でした。
猶予つきの変更: module.register()
ESM ローダーフックの登録に使う module.register() は、26 系でも動作しますが警告が出ます。
(node:14232) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
即時の破壊ではないので移行と同時に直す必要はありませんが、警告がログを汚すため、テスト実行の出力を機械的に検査している環境では先に潰しておくほうが楽です。
束ねてわかったこと
3 系統の破壊的変更は「22 系では無言で動き続ける」性質を共有しています。22 系で CI を緑にしたまま移行前チェックを済ませたつもりになれる状態が長く続き、26 系に上げた瞬間に初めて MODULE_NOT_FOUND と TypeError として現れます。
事前検出の手段として --throw-deprecation を思い浮かべますが、実測した範囲では取りこぼしがありました。このフラグが失敗に変えてくれるのは DEP0125(_stream_wrap)や DEP0205(module.register)のように警告が出るものだけで、_stream_readable の読み込みも res.writeHeader() の呼び出しも警告を出さないため素通りします。22 系での静的な洗い出し(grep)と、26 系を CI マトリクスに 1 本足すことの組み合わせが、結局いちばん確実でした。
タイミングの面では、Node.js の公式リリーススケジュールで 26 系の LTS 入りが 2026 年 10 月 28 日、22 系のサポート終了が 2027 年 4 月 30 日と決まっています。22 系にはまだ 8 か月以上の猶予があるため、10 月の LTS 化を待ってから上げる判断でも間に合います。ただし ABI 更新を伴うネイティブアドオンの棚卸しだけは、猶予とは無関係に先に着手できます。
再現手順
検証は次の 2 コマンドで再現できます。公式ビルドを展開するだけなので、システムの Node.js には触れません。
# 26 系の公式ビルドを取得(Linux x64)
curl -sSL -o node26.tar.xz https://nodejs.org/dist/v26.6.0/node-v26.6.0-linux-x64.tar.xz
tar xf node26.tar.xz
# 同じスクリプトを両方のランタイムで実行して差分を取る
node probe.cjs > v22.txt
./node-v26.6.0-linux-x64/bin/node probe.cjs > v26.txt
diff v22.txt v26.txt
probe.cjs には、'Temporal' in globalThis、typeof Map.prototype.getOrInsert、require('_stream_readable') の成否、typeof http.ServerResponse.prototype.writeHeader、process.versions.modules を出力するだけの行を並べておけば足ります。プロジェクト固有の判定を足すなら、依存パッケージの require をこのファイルに列挙して 26 系で落ちるものを洗い出すのが手早い方法でした。
移行チェックリスト
-
grep -rn "_stream_" src/ node_modules/で旧ストリームモジュールの参照を洗う -
grep -rn "writeHeader(" src/で削除済みメソッドの呼び出しを洗う -
npm ls --depth=0からネイティブアドオンを拾い、ABI 147 向けの再ビルド可否を確認する - 22 系の CI に
--throw-deprecationを足し、DEP0125 や DEP0205 を失敗として顕在化させる(警告の出ない_stream_readableとwriteHeader()は拾えないため、これ単独では足りません) - 26 系を CI マトリクスに 1 本追加し、起動と主要な統合テストだけ通す
- タイムゾーンつきの暦計算がある箇所だけ、
Temporal.ZonedDateTimeへの置き換えを検討する
関連記事
- OpenCodeのplanモード、read-onlyは権限ルールだけで作られていた
- MCP SDK v2のステートレス化、旧クライアントは無改造で動いた
- Claude Codeのauto mode既定化、denyとhookは4モードとも効いた