JSON.parse のパフォーマンス:V8 は巨大なペイロードをどう処理するか
現代の V8(Node.js / Chrome / Deno のエンジン)は、JSON.parse に汎用の再帰下降パーサーを使っていません。2019 年以降、V8 は C++ で書かれた専用の fast-path パーサーを搭載しており、UTF-16 文字列を直接処理し、young generation にオブジェクトを割り当て、transition tree(hidden class)によって配列内の兄弟オブジェクト間で形状(shape)情報を共有させます。[{"id":1,"name":"a"},{"id":2,"name":"b"}, ...] のようなペイロードでは、最初のオブジェクト以降はすべて同じ hidden class で生成されるため、その後のプロパティ参照は monomorphic になりインライン化されます。
JSON.parse のコストを支配するのは次の 3 つです。(1) 文字列のトークン化とアンエスケープ、(2) 生成されるオブジェクトグラフによる GC 圧力、(3) reviver コールバックのコスト。reviver 関数を追加することは、パフォーマンス面で最大の落とし穴です。V8 は低速な汎用パスへ強制的に切り替えられ、ツリー内のすべてのキーに対して JS 関数が呼び出されます。10 MB のペイロードでは通常 4〜8 倍の差が出ます。少数のフィールドだけを変換したいのであれば、先にパースしてから結果を自分で走査するほうが得策です。
同質なオブジェクトからなる非常に大きな配列では、fast path が極めてよく最適化されているため、V8 がストリーミング JSON ライブラリを上回ることも珍しくありません。しかし単一のドキュメントが約 100 MB を超えると、JSON.parse は元の生文字列と結果のオブジェクトツリーを同時にメモリへ保持するため、ピーク RSS がファイルサイズの 3〜4 倍に達することがあります。その段階では simdjson、oboe.js、stream-json のようなストリーミングパーサーへ切り替えてください。これらはトークンが生成されるたびにイベントを発行し、完全なツリーを実体化することはありません。
さらに V8 は呼び出し間でパーサーのトークン化バッファをキャッシュするため、多数の小さなペイロードをタイトなループで JSON.parse するほうが、同じ総バイト数を一度の巨大な呼び出しでパースするより高速です。ワイヤーフォーマットを自分で決められるなら NDJSON(1 行につき JSON オブジェクト 1 個)を選びましょう。インクリメンタルにパースでき、行単位でエラーから復旧でき、単純な行バッファリング IO とも相性が良いからです。
// SLOW: reviver kills the fast path
const data = JSON.parse(raw, (key, val) => {
if (key === 'createdAt') return new Date(val);
return val;
});
// FAST: parse first, walk later
const data = JSON.parse(raw);
function walk(o) {
for (const k in o) {
if (k === 'createdAt' && typeof o[k] === 'string') o[k] = new Date(o[k]);
else if (o[k] && typeof o[k] === 'object') walk(o[k]);
}
}
walk(data);
// FASTER for huge files: NDJSON
import readline from 'node:readline';
const rl = readline.createInterface({ input: fs.createReadStream('big.ndjson') });
for await (const line of rl) {
const row = JSON.parse(line); // 1 record at a time
process(row);
}本番環境で必ず遭遇する JSON の落とし穴
JSON は一見シンプルで、まさにそれゆえに足をすくわれます。RFC 8259 は文法を厳密に定義しています。数値は IEEE-754 の double のみ、リテラルは true / false / null だけ、文字列は二重引用符で囲んだ UTF-8 でなければならず、ネイティブの日付型・コメント・末尾カンマ・Infinity / NaN は一切ありません。JSON にまつわる「本番環境の奇妙なバグ」は、どれもこの 6 つのルールのいずれかが現実によって破られたものです。
NaN と Infinity は有効な JSON 値ではありません。JavaScript の JSON.stringify はこれらを黙って null に変換するため、{ "p99": Infinity } のようなメトリクスは { "p99": null } としてシリアライズされ、ダッシュボードは何事もなかったかのようにゼロを表示します。対策は、シリアライズ前にこれらの値をクランプするか特別扱いすることです。ラウンドトリップを決して信用しないでください。
Date オブジェクトのラウンドトリップは非対称です。JSON.stringify(new Date()) は Date.prototype.toJSON メソッド経由で ISO-8601 文字列を出力しますが、JSON.parse はその文字列が日付であることを知りません。返ってくるのは Date ではなく文字列です。ネットワーク往復の後に data.createdAt.getTime() を呼ぶコードは例外を投げます。既知のスキーマ(zod、valibot、ajv)でパース処理をラップするか、タイムスタンプは文字列として返ってくることを明示的にドキュメント化してください。
BigInt はハードエラーです。JSON.stringify(1n) は TypeError: Do not know how to serialize a BigInt を投げます。64 ビット ID を BigInt で返すデータベースドライバー(Postgres の bigint、MySQL の UNSIGNED BIGINT)では絶えずこの問題に遭遇します。現実的な対処は、(a) アプリ起動時に BigInt.prototype.toJSON = function() { return this.toString() } を定義し、受信側が文字列を受け取ることを受け入れるか、(b) superjson のように型を保存するシリアライザーを使うことです。
循環参照も TypeError になります。DOM ツリーから parent ポインタを持つグラフデータ構造まで、あらゆるものが JSON.stringify を爆発させます。replacer パラメータを使えば既知の循環キーをスキップできます(undefined を返すとそのキーは落とされます)。デバッグ用途なら、util.inspect や structuredClone(内部で循環を検出します)のほうが扱いやすいでしょう。
最後にキーの順序について。仕様上オブジェクトは順序を持たないとされていますが、V8 の JSON.stringify は整数風のキーを先頭に、その後は挿入順でキーを出力します。テストが JSON 文字列の完全一致をアサートしているなら、それは実装の詳細をテストしていることになります。代わりにパース済みのオブジェクトに対してアサートしてください。
// NaN/Infinity silently become null
JSON.stringify({ p99: NaN, max: Infinity });
// => '{"p99":null,"max":null}' (BUG: silent data loss)
// Date asymmetric round-trip
const o = { at: new Date() };
const back = JSON.parse(JSON.stringify(o));
back.at.getTime(); // TypeError: back.at.getTime is not a function
// BigInt fix at app boot
BigInt.prototype.toJSON = function () { return this.toString(); };
// Circular reference handler
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[Circular]';
seen.add(v);
}
return v;
});
}JSON vs JSON5 vs JSONC — 正しい方言の選び方
JSON そのものは凍結された標準です。RFC 8259 が仕様であり、人間に優しい機能は意図的に省かれています。この禁欲的な設計はワイヤーフォーマットには最適ですが、手で編集する設定ファイルには最悪です。だからこそ、設定ファイルの世界では 2 つの非公式方言が主流を占めています。
JSONC(「JSON with comments」)は、VS Code、TypeScript の tsconfig.json、そして Microsoft 系ツールの多くが採用している方言です。RFC 8259 に追加するのは正確に 2 つだけ、行コメント(//)とブロックコメント(/* */)です。末尾カンマは VS Code のパーサーでは許容されますが、成文化された仕様には含まれていません。JSONC は意図的にミニマルであり、コメントを除けば、すべての JSONC ドキュメントは JSON ドキュメントです。TypeScript プロジェクトで追加のライブラリをインストールする必要はありません。コンパイラ自身が寛容なパーサーを備えており、小さな正規表現でコメントを取り除けば(文字列内の扱いには注意)、有効な JSON になります。
JSON5 はもっと野心的です。単一引用符の文字列、引用符なしの ECMAScript-5 識別子キー、複数行文字列、16 進数、先頭・末尾の小数点(.5、5.)、数値の正符号、NaN と Infinity、そして末尾カンマを追加します。その代償として、JSON5 はもはや JSON のサブセットではありません。JSON5 ドキュメントを JSON.parse に渡すことはできません。人間が主役で、機械側は json5 npm パッケージを使える設定ファイルにだけ使ってください。ワイヤーフォーマットとしては決して使わないでください。
3 つ目の候補である HJSON は、キーだけでなく短い文字列値からも引用符を取り除きます。JSON5 よりさらに寛容ですが、エコシステムの対応範囲は狭くなります。使っているツールがすでに HJSON を標準としている場合を除き、避けるべきです。
経験則はこうです。API とプロセス間メッセージは、最大限の互換性のために RFC 8259 の厳密な JSON にとどめてください(どの言語にも安定したパーサーがあります)。人間が実際に読んで編集するエディタ設定やビルド設定には、コメントを気軽に書ける JSONC が向いています。JSON5 に手を伸ばすのは、その豊富な機能が必要で、かつ書き手と読み手の両方を自分で管理できる場合だけです。そして、JSON5 ドキュメントを JSON を期待するシステムに漏らしてはいけません。その不一致は、最悪のタイミングで不可解な「Unexpected token」エラーを引き起こします。
// JSON (RFC 8259) — strict
{
"name": "app",
"ports": [3000, 3001]
}
// JSONC — comments + (de facto) trailing commas
{
// tsconfig.json style
"compilerOptions": {
"target": "ES2022", /* matches Node 18+ */
"strict": true,
},
}
// JSON5 — humans first
{
name: 'app', // unquoted key, single-quoted string
port: 0xABCD, // hex
rate: .5, // leading decimal
retries: +Infinity, // signed Infinity
// multi-line string
banner: "line1\
line2",
}