Tips & Tricks (更新: 2026/6/7)

処理が遅いNodeを推測で直すな:計測でボトルネックを特定して速くする手順

遅い処理を勘で直すと別の場所を直して終わる。計測でボトルネックを特定し、N+1・直列await・無駄な再計算を潰してサーバー処理を速くする実践手順を、コピペで動くコード付きで。

処理が遅いNodeを推測で直すな:計測でボトルネックを特定して速くする手順

「このAPI、なんか遅いんで速くしといて」

そう言われて、僕は迷わずデータベースにインデックスを足しました。半日かけて。結果、応答時間は1ミリ秒も変わりませんでした。

あとで計測してみたら、遅さの正体はDBじゃなかった。ループの中で外部APIを1件ずつ叩いていた箇所が、全体の9割の時間を食っていたんです。僕はその9割を一度も見ずに、残りの1割を磨いていた。

これ、笑い話じゃありません。遅いコードを「勘」で直すと、たいてい関係ない場所を直して終わります。速くする作業でいちばん最初にやるべきは、コードを書くことでも設定をいじることでもなくて、どこが遅いのかを数字で突き止めることです。

なお、この記事はサーバー側・データ処理・バッチスクリプトといった「処理そのものの遅さ」の話です。ブラウザの表示が遅い(画面がカクつく、初回表示が重い)話は別物なので、そちらは Webパフォーマンス改善はCore Web Vitalsを測ってから直す を読んでください。狙う指標も直し方もまったく違います。

この記事の要点

  • 速くする前に必ず計測する。推測で直すと、9割の時間を食っている箇所を見逃して1割を磨くことになる。
  • まずは雑でいい。console.timeperformance.now() で「どの行に何ミリ秒かかったか」を出すだけで、犯人の8割は見える。
  • よくある犯人は4つ。N+1(ループ内クエリ/API)直列の await無駄な再計算巨大ループの中の重い処理
  • 直し方も4つに対応する。まとめて取得(バッチ化)Promise.all で並列化キャッシュ/メモ化アルゴリズムとDBインデックス
  • 直したら同じ計測をもう一度回して「本当に速くなったか」を数字で確認する。体感で判断しない。

なぜ「勘で直す」と失敗するのか

人間の直感は、遅さの原因を当てるのがとにかく下手です。

理由はシンプルで、僕らは「複雑そうに見えるコード」を遅いと感じてしまうから。ネストが深い関数、長い正規表現、難しそうなアルゴリズム。でも実際にCPUやネットワークを食っているのは、たいてい見た目が地味な1行だったりします。ループの中の await db.query(...) とか、毎回ファイルを読み直している箇所とか。

プログラムの実行時間は、ほとんどの場合ごく一部に偏ります。「全体の95%の時間を、コード全体の5%が消費している」みたいな状態がふつうです。だから、その5%を見つけずに残りをいくら磨いても、体感は1ミリも変わりません。冒頭の僕がまさにこれでした。

やることの順番は、いつも同じでいいです。

  1. 計測して、いちばん時間を食っている箇所を1つ特定する
  2. そこだけを直す
  3. もう一度計測して、本当に速くなったか確認する
  4. まだ遅ければ、次に重い箇所へ移る

この記事ではNode.js(バックエンドのJavaScript/TypeScript)を例にしますが、考え方はPythonでもGoでも同じです。

まず雑に計測する:console.timeperformance.now

いきなり高機能なプロファイラを入れる必要はありません。最初は「どの処理に何ミリ秒かかったか」がざっくり分かれば十分です。

いちばん手軽なのが console.time / console.timeEnd のペアです。囲んだ区間の経過時間を勝手に出してくれます。

// ラベルを揃えた console.time / timeEnd で区間の所要時間を出す
console.time("ユーザー取得");
const users = await fetchAllUsers();
console.timeEnd("ユーザー取得"); // 例: ユーザー取得: 1843.201ms

console.time("集計");
const report = buildReport(users);
console.timeEnd("集計"); // 例: 集計: 12.044ms

この2行を見るだけで、「集計は12ミリ秒なのに、取得に1.8秒かかっている」とすぐ分かります。直すべきは取得の方です。集計を最適化しても誤差にしかなりません。

もう少し細かく測りたいときは performance.now()(Node.js公式ドキュメント)を使います。ミリ秒を小数で返してくれるので、数値を自分で引き算して扱えます。区間ごとの時間を配列にためて、あとで一覧にすると犯人が一目瞭然です。

import { performance } from "node:perf_hooks";

// 区間ごとの所要時間をためて、最後に遅い順で並べる簡易プロファイラ
function makeTimer() {
  const records = [];
  return {
    async measure(label, fn) {
      const start = performance.now();
      const result = await fn();
      records.push({ label, ms: +(performance.now() - start).toFixed(1) });
      return result;
    },
    report() {
      records
        .sort((a, b) => b.ms - a.ms)
        .forEach((r) => console.log(`${r.ms.toString().padStart(8)} ms  ${r.label}`));
    },
  };
}

const t = makeTimer();
const users = await t.measure("DBからユーザー取得", () => fetchAllUsers());
const orders = await t.measure("注文を取得", () => fetchOrders(users));
const report = await t.measure("レポート生成", () => buildReport(users, orders));
t.report();
// 出力例:
//   1820.4 ms  注文を取得      ← ここが犯人
//    240.7 ms  DBからユーザー取得
//     11.9 ms  レポート生成

この出力が出た瞬間に、議論は終わります。「注文を取得」が全体の9割近い。レポート生成をいじる手は止めて、注文取得の中身を見にいきます。

CPUを食う処理(重い計算ループなど)を関数単位で細かく見たいときは、Node.js組み込みのプロファイラが使えます。node --prof(公式CLIドキュメント)で実行ログを取り、node --prof-process isolate-*.log で関数別の負荷ランキングが出ます。ただ、ほとんどの実務では上の簡易タイマーで犯人にたどり着けるので、いきなり重装備にしなくて大丈夫です。

よくある犯人① N+1:ループの中で1件ずつ問い合わせる

計測すると、遅さのかなりの割合がこれです。N+1問題

名前は難しそうですが、中身は単純です。「一覧を1回取ってきて(1回)、その各行についてもう1回ずつ問い合わせる(N回)」。合わせてN+1回。レストランで例えるなら、10人分の注文を聞くのに、1人ずつ厨房まで往復しているようなものです。10往復。まとめて1往復で済むのに。

コードだとこう見えます。

// 悪い例: ユーザーごとに注文をDBへ問い合わせる(1 + N回)
const users = await db.query("SELECT * FROM users");
for (const user of users) {
  user.orders = await db.query("SELECT * FROM orders WHERE user_id = $1", [user.id]);
}

ユーザーが1000人いれば、DBへのアクセスは1001回。1回あたり2ミリ秒でも、それだけで2秒です。

直し方は「まとめて取る」。ユーザーIDを全部集めて、IN 句で一発で注文を取り、メモリ上で紐付けます。

// 良い例: 注文をまとめて1回で取り、JS側で紐付ける(合計2回)
const users = await db.query("SELECT * FROM users");
const ids = users.map((u) => u.id);

const orders = await db.query(
  "SELECT * FROM orders WHERE user_id = ANY($1)",
  [ids]
);

// user_id ごとにグループ化してから割り当てる
const byUser = new Map();
for (const o of orders) {
  if (!byUser.has(o.user_id)) byUser.set(o.user_id, []);
  byUser.get(o.user_id).push(o);
}
for (const user of users) {
  user.orders = byUser.get(user.id) ?? [];
}

DBアクセスは1001回から2回へ。これだけで、さっきの2秒が数十ミリ秒になります。ORM(PrismaやTypeORMなど)を使っているなら、関連を一括取得する機能(Prismaなら include、多くのORMで「eager loading」と呼ばれる仕組み)があるので、ループ内で1件ずつ取る書き方を見つけたらまずそれを疑ってください。

SQL側の遅さ、たとえばINで取ってもまだ重い、インデックスが効いていない、といった話は SQLが遅い原因をEXPLAINで突き止めて直す手順 に計測手順込みでまとめてあります。EXPLAINの読み方はそっちが詳しいです。

よくある犯人② 直列の await:待たなくていいのに順番待ち

次に多いのが、独立した非同期処理を1つずつ順番に待っているケースです。

await は便利ですが、書いた順に「終わるまで待つ」ので、何も考えずに並べると全部直列になります。お互い関係ない3つのAPI呼び出しでも、こう書くと足し算の時間がかかります。

// 悪い例: 3つの独立した取得を直列に待つ(合計 = 全部の足し算)
const user = await fetchUser(id);       // 300ms
const orders = await fetchOrders(id);   // 400ms
const reviews = await fetchReviews(id); // 350ms
// 合計 約1050ms

この3つは互いに依存していません。orders を取るのに user の結果は要らない。だったら同時に投げて、まとめて待てばいい。Promise.all を使います。

// 良い例: 独立した取得を並列に投げて、まとめて待つ(合計 = いちばん遅い1つ)
const [user, orders, reviews] = await Promise.all([
  fetchUser(id),
  fetchOrders(id),
  fetchReviews(id),
]);
// 合計 約400ms(いちばん遅い orders に律速される)

1050ミリ秒が400ミリ秒に縮みます。3つを別々の人に同時に頼むか、1人に順番にやらせるかの違いです。

注意点が2つ。1つ目、依存関係があるものは並列にできませんuser.companyId を使って会社情報を取るなら、それは順番に待つしかない。2つ目、外部APIを一気に何百件も並列で叩くと、相手のサーバーに怒られる(レート制限やタイムアウト)ことがあります。件数が多いときは「同時に5件まで」のように上限を付けて並列化します。p-limit のようなライブラリ1行で実現できます。

よくある犯人③ 無駄な再計算:同じ答えを何度も作り直す

計測していると、「さっきと同じ計算を、また最初からやっている」箇所がよく見つかります。

たとえばループの中で、毎回同じ設定ファイルを読み直す。毎回同じ重い変換を呼ぶ。引数が同じなら答えも同じなのに、毎回ゼロから計算する。これは、買い物のたびに財布の中身を全部数え直すようなもので、一度数えて覚えておけば済む話です。

「同じ入力なら結果を使い回す」のを**メモ化(memoization)**と呼びます。Map を1つ用意して、計算前にキャッシュを覗くだけです。

// 重い計算を引数ごとにキャッシュして、2回目以降は即返す
function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (cache.has(arg)) return cache.get(arg); // 計算済みなら即返す
    const value = fn(arg);
    cache.set(arg, value);
    return value;
  };
}

const heavyFormat = memoize((code) => {
  // 例: 通貨フォーマッタの生成は地味に重い。同じcodeなら使い回す
  return new Intl.NumberFormat("ja-JP", { style: "currency", currency: code });
});

// ループで何万回呼ばれても、生成は通貨の種類ぶんだけで済む
for (const row of millionRows) {
  row.label = heavyFormat(row.currency).format(row.amount);
}

ループの外に出せる処理を中に置いているだけ、というパターンも多いです。「この計算、ループのたびに変わる?」と一度自問するだけで、けっこう削れます。

リクエストをまたいで結果を使い回したい(DBの結果やAPIレスポンスを一定時間キャッシュしたい)場合は、メモリ内Mapではなくちゃんとしたキャッシュ層が要ります。TTL(有効期限)の決め方、古いデータが残る事故の防ぎ方は Webキャッシュ戦略の決め方:Cache-Control・CDN・Redis無効化の実装 にまとめました。キャッシュは速くする代わりに「古い情報を返す」リスクを背負うので、消し方をセットで考えるのが大事です。

よくある犯人④ 巨大ループとアルゴリズム:件数が増えると爆発する

データが少ないうちは速いのに、件数が増えた途端に固まる。これはアルゴリズムの問題であることが多いです。

典型が「二重ループでの突き合わせ」。2つの配列を照合するのに、外側のループの各要素について内側を全部なめる。件数が10倍になると、処理時間は100倍になります。1000件で耐えていたものが、1万件で死ぬ。

// 悪い例: 二重ループで突き合わせ(件数の2乗で遅くなる)
for (const order of orders) {
  const user = users.find((u) => u.id === order.userId); // 毎回 users 全部を走査
  order.userName = user?.name;
}

直し方は、片方を一度だけ Map にしてしまうこと。Map からの取り出しは件数が増えてもほぼ一定時間です。「毎回探す」を「一度だけ索引を作る」に変えるイメージです。

// 良い例: 先にMapにしてからO(1)で引く(件数が増えても線形で済む)
const userById = new Map(users.map((u) => [u.id, u]));
for (const order of orders) {
  order.userName = userById.get(order.userId)?.name;
}

見た目はほとんど変わりませんが、1万件・10万件と増えたときの差は劇的です。.find().includes() をループの中で呼んでいたら、だいたいこの形に直せます。

DB側でも同じで、毎回テーブルを全部なめている(フルスキャン)なら、検索条件の列にインデックスを張るだけで桁違いに速くなります。どの列に張るべきか、複合インデックスの順番はどう決めるか、といった設計判断は テーブル設計で詰む前に。正規化・リレーション・インデックスの判断軸 が参考になります。

Claude Codeに「速くして」と頼むときのコツ

ここまでの作業、Claude Codeに任せると速いです。ただし任せ方にコツがあります。

僕がやらかしたのは、いきなり「このAPIを速くして」と丸投げしたこと。Claude Codeは気を利かせて、計測もせずにあれこれ「速そうな書き換え」を提案してきました。でもそれは冒頭の僕と同じで、犯人を特定する前に直し始めている。

うまくいったのは、計測を先にやらせる頼み方です。

claude -p "src/api/report.ts が遅い。まず推測で直さず、各処理に performance.now() で計測コードを入れて、所要時間を遅い順に出力するパッチだけ作って。
直すのはその後、いちばん遅い1箇所だけ。N+1・直列await・二重ループのどれかを疑って。
変更したファイルと、計測の前後の数値を報告して。"

「直す前に計測しろ」「いちばん遅い1箇所だけ」「前後の数値を報告しろ」。この3つを指示に入れるだけで、的外れな書き換えが激減します。Claude Code自体の応答が遅い・重いと感じる場合の対処(コンテキストの整理など)は別の話なので、それはそれで切り分けてください。

よくある質問

Q. 計測ツールは何を入れればいいですか? まずは何も入れなくていいです。console.time / console.timeEndperformance.now() はNode.js標準なので、追加インストールなしで今すぐ使えます。それで犯人が分からないほど込み入った計算をしているときだけ、node --prof やClinic.jsのような専用ツールを検討してください。

Q. N+1かどうかを見分けるコツは? ループ(for / map / forEach)の中に await が入っていて、それがDBクエリかAPI呼び出しなら、まずN+1を疑ってください。SQLログを出せる環境なら、1リクエストで同じようなSELECTが何十回も流れていないか見るのが確実です。

Q. なんでも Promise.all で並列化すれば速くなりますか? いいえ。並列化が効くのは「お互いに依存しない処理」だけです。前の結果を次で使うなら直列にするしかありません。また外部APIを無制限に並列化すると相手に負荷をかけ、レート制限で逆に遅くなります。件数が多いときは同時実行数に上限を付けてください。

Q. マイクロ最適化(for vs forEach の速さ比べなど)はやる価値ありますか? ほとんどの場合ありません。そういう差はナノ秒〜マイクロ秒の世界で、実務の遅さはミリ秒〜秒の世界(DB・ネットワーク・無駄な再計算)で起きています。計測して上位に出てこない限り、ループの書き方の好みで悩む時間はもったいないです。

Q. 本番でも計測コードを入れっぱなしにしていいですか? 区間タイマー程度なら負荷はほぼ無視できますが、出力先は標準出力に垂れ流すのではなく、ログ基盤やAPM(アプリ性能監視ツール)に送るのが筋です。console.time のような開発用の出力は、本番では構造化ログに置き換えるのがおすすめです。

実際に試した結果

冒頭で半日を溶かした僕は、それ以来「速くして」と言われたらまず計測コードから書くようになりました。

直近で効いたのは、夜間バッチが3時間半かかっていたやつです。例の簡易タイマーを挟んだら、外部APIをユーザーごとに1件ずつ叩いている箇所が全体の8割を食っていました。完全なN+1。そこを「同時に5件まで」で並列化しただけで、3時間半が40分を切りました。DBにもアルゴリズムにも、一切触っていません。

学んだのは、速くする作業の9割は「直す技術」じゃなくて「どこを直すか当てる技術」だ、ということです。当てるための唯一の方法が計測です。勘で直していた頃の僕は、たまたま当たれば速くなり、外れれば半日溶かしていました。計測してから直すようにしてからは、外れがなくなりました。

遅いと感じたら、まず1行、console.time を置いてみてください。たぶんそれだけで、犯人の見当がつきます。

手元のプロジェクトで本格的に詰めたくなったら、関連する 教材一覧相談・研修 も覗いてみてください。

#Node.js #パフォーマンス #計測 #N+1 #プロファイリング
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。