何をもって「パフォーマンスが悪い」というのか
まず、測りたい指標を分けておきます。
指標
- レイテンシ(応答時間)
- ボタンを押してから画面が反応するまでの時間
- APIリクエストからレスポンスまでの時間
- スループット
- サーバが1秒間にさばけるリクエスト数
- バッチが1分間に処理できるレコード数
- メモリ使用量
- タブを開きっぱなしにしたときに、どんどんメモリが増えていないか(メモリリーク)
- Nodeサーバがメモリを食い続けてOOM(Out Of Memory)にならないか
- フレームレート(ブラウザ)
- スクロール・アニメーションが60fps前後で滑らかか
- JSやレイアウトが重すぎてカクカクしていないか
大雑把にいうと:
- ブラウザ → 体感のサクサク感 / カクつき
- Nodeサーバ → 1リクエストの遅さ+同時接続に対する強さ
- どちらも → メモリリークがないか
を見ていくことになります。
「まずは測る」が大前提
最適化の鉄則:計測せずに最適化しない(premature optimizationを避ける)
手軽な計測の例
- JSレベル:
console.time("label")/console.timeEnd("label")performance.now()(高精度なタイムスタンプ)
- ブラウザDevTools:
- Performanceタブ(フレームごとの処理時間・レイアウト・スクリプト)
- Networkタブ(APIレスポンス、静的ファイルのサイズ)
- Memoryタブ(ヒープスナップショット、リーク検知の足がかり)
- Node.js:
process.hrtime.bigint()など高精度タイマーprocess.memoryUsage()でメモリ使用量node --inspectでChrome DevToolsからCPUプロファイル / ヒープダンプ
「なんとなく遅い気がする」ではなく、
どの処理が何ミリ秒かかっているか を最初に数字に落とすのが本当に大事です。
アルゴリズムとデータ構造の視点
JS特有というよりプログラミング一般の話ですが、パフォーマンスの根本はやはりここです。
オーダー(Big-O)
- O(1):定数時間(配列の
arr[i]参照など) - O(n):要素数に比例(1回ループ)
- O(n²):2重ループ(全ペア比較など)
- O(n log n):ソートなどでよく出る
「何万件もあるデータに O(n²) をかけていないか?」
というのが、まず最初に疑うポイント。
例:
// 悪い例:毎回indexOfで探索(O(n^2)になりがち)
function uniqueSlow(arr) {
const result = [];
for (const item of arr) {
if (result.indexOf(item) === -1) {
result.push(item);
}
}
return result;
}
// 良い例:Setを使ってO(n)で重複排除
function uniqueFast(arr) {
return [...new Set(arr)];
}配列 vs Map / Set
- 配列で「含まれているかどうか」を毎回
indexOfで調べる → O(n) Setで管理 → 平均O(1)相当(ハッシュ構造)
const set = new Set(arr);
console.log(set.has(value));- 連番インデックスで順番に処理 → 配列が適している
- 「キー→値」のマップ →
Mapが適している(Objectより柔軟)
アルゴリズムとデータ構造をちゃんと選ぶだけで、
秒単位の処理が一瞬で終わる、ということもよくあります。
ブラウザ JS のパフォーマンス
メインスレッドをブロックしない
ブラウザは基本的に:
- JSの実行
- レイアウト(レイフロー)
- ペイント
- 入力イベント処理
を同じメインスレッドでやっています。
ここで重い同期処理を書くと:
- その間、描画もイベント処理も止まる
- 結果として「フリーズしたように見える」
やってはいけない例:
// 数百ミリ秒〜数秒かかる巨大なループ
button.addEventListener("click", () => {
for (let i = 0; i < 1_000_000_000; i++) {
// 重い計算
}
});回避パターン
- 処理を「小分け」にして
setTimeout/requestIdleCallbackで分散 - Web Worker を使って、別スレッドで重い計算を行う(メインスレッドは軽く)
シンプルな分散の例:
function processLargeArray(items, chunkSize = 1000) {
let index = 0;
function nextChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
// 要素items[index]を処理
}
if (index < items.length) {
setTimeout(nextChunk, 0); // 次のタスクとして回す
}
}
nextChunk();
}DOM操作は「回数」と「タイミング」がコスト
DOM操作自体はかなり高コストです:
- 新しい要素の生成・挿入
- レイアウト情報の参照(
offsetWidth,getBoundingClientRect()など) - スタイル変更・クラスの変更
これを細かく何度も往復すると、レイアウト計算やペイントが頻発して重くなります。
悪い例(ループ中に毎回DOMいじる)
const list = document.querySelector("#list");
for (let i = 0; i < 10000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
list.append(li); // 1万回挿入
}少しマシな例(DocumentFragmentでまとめて)
const list = document.querySelector("#list");
const frag = document.createDocumentFragment();
for (let i = 0; i < 10000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
frag.append(li);
}
list.append(frag); // 1回の挿入で済むレイアウトスラッシングを避ける
「レイアウトの読み取り」と「書き込み」が交互に行われると負荷が増えます。
悪いパターン:
for (const el of items) {
const width = el.offsetWidth; // レイアウト読み取り
el.style.width = width / 2 + "px"; // 書き込み
}より良いパターン:
- 先に一度レイアウト情報を全部読む
- そのあとで全部書き込む
スクロール/resize のハンドラは debounce / throttle
スクロールや resize イベントは、1秒間に何十回〜何百回 も発生します。
重い処理を直接ぶら下げると、すぐカクつきの原因になります。
debounce のイメージ(最後の1回だけ実行)
function debounce(fn, delay) {
let timerId = null;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => fn(...args), delay);
};
}
window.addEventListener(
"resize",
debounce(() => {
console.log("リサイズ終了っぽいので、ここで重い処理");
}, 200)
);throttle のイメージ(多くても一定間隔でしか実行されない)
function throttle(fn, interval) {
let lastTime = 0;
return (...args) => {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn(...args);
}
};
}
window.addEventListener(
"scroll",
throttle(() => {
console.log("スクロール中、最大でも100msごとに実行");
}, 100)
);メモリとGC:リークパターンを押さえる
JSのメモリ管理の前提(おさらい)
- オブジェクトは「到達可能」である限りGCされない
- 「到達不能」になったオブジェクトは、どこかのタイミングでGCにより回収される
- 開発者が直接
freeしたりはできない
よくあるメモリリークパターン
グローバル配列・Mapに溜めっぱなし
// 悪い例:履歴を無限に溜め続ける
const cache = [];
function addLog(entry) {
cache.push(entry);
}対策:
- サイズに上限を設ける(LRUキャッシュなど)
- 本当に必要なものだけ保持する
- いらなくなったタイミングで
length = 0やclear()で解放
DOM要素を削除したのに、JSから参照を残してしまう
let lastClickedElement = null;
document.addEventListener("click", (e) => {
lastClickedElement = e.target;
});
// DOM上から要素をremoveしても、lastClickedElementが参照している間はGCされない可能性- 特に SPA で、「古い画面の DOM への参照」をどこかに残したまま新しい画面を作っていくと、
タブを開きっぱなしでメモリが増え続ける、という問題になりやすい
対策:
- 画面遷移時に不要な参照を
null代入で切る - 大きなDOMを掴んでいるオブジェクトの寿命を必要最小限にする
イベントリスナの付けっぱなし
function attach() {
window.addEventListener("resize", onResize);
}
function detach() {
window.removeEventListener("resize", onResize);
}
function onResize() {
// ...
}attach だけが何度も呼ばれ、detach が呼ばれないと:
- リスナがどんどん増え続ける
- コールバッククロージャが大きなオブジェクトを参照していると、それも生存し続ける
長寿命オブジェクト(window, document)にリスナを付ける時は特に要注意。
setInterval / setTimeout のクリア忘れ
function startPolling() {
const id = setInterval(() => {
// 重い処理
}, 1000);
// idをどこにも保存せず、clearIntervalも呼ばれない
}- 画面遷移やコンポーネント破棄後も、タイマーは生き続ける
- そのコールバックがクロージャで大きなデータにアクセスしていると、それも永遠に解放されない
対策:
idをきちんと保持し、終了時にclearInterval(id)/clearTimeout(id)を呼ぶ- SPAフレームワーク(React/Vueなど)なら、アンマウント時に必ずcleanupを書く
クロージャが巨大オブジェクトを捕まえ続ける
function createHandler(hugeData) {
return function () {
// hugeData を少しだけ使う
};
}
const handler = createHandler(veryBigArray);
button.addEventListener("click", handler);veryBigArrayを丸ごとクロージャで捕まえてしまうと、handlerが生きている間は解放できない- 実際に必要な部分だけを抜き出して渡すなどの工夫が必要
Node.js におけるパフォーマンスとメモリ
ブラウザと共通する部分も多いですが、サーバならではのポイントを整理します。
Nodeの強みと弱み
- 強み:
- 非同期I/O(ネットワーク、ファイル)に強い
- 多数の同時接続を軽いリソースでさばける
- 弱み:
- シングルスレッドでCPUバウンドな処理は苦手(大きなforループ、重い暗号化など)
やってはいけないパターン(ブロッキング)
// 悪い例:同期I/O
app.get("/data", (req, res) => {
const text = fs.readFileSync("/big/file.txt", "utf8"); // ブロッキング
res.send(text);
});- このリクエストを処理している間、同じプロセスの他のリクエスト処理もブロックされる
- 高負荷時に一気にスループットが落ちる
対策:async/awaitで非同期処理する
app.get("/data", async (req, res) => {
const text = await fs.promises.readFile("/big/file.txt", "utf8"); // 非同期
res.send(text);
});CPUバウンド処理の扱い
- 長時間かかる計算(画像変換・動画エンコード・大規模な暗号化など)は
- 別プロセス(
child_process) worker_threads- 専門のバッチワーカーやキュー(Redis+専用プロセスなど)
- 別プロセス(
に逃がす設計にする。
Node.js環境でのメモリリーク例
- グローバルキャッシュにリクエストごとのデータを溜め続ける
- Expressの
req/resオブジェクトを、非同期処理用にどこかに保存してしまう - 永続的なMap/Arrayにどんどんpushし続けてclearしない
process.memoryUsage() でヒープ使用量を時系列で観察したり、ヒープダンプを取って分析するのが、本格的なリーク調査の入り口です。
パフォーマンスチューニングの「正しい進め方」
最後に、「どう進めるべきか」のプロセスをまとめます。
ステップ1:目的と指標を決める
- ページロードを 3秒以内 にしたいのか
- 特定APIのP95レスポンスを 200ms以下 にしたいのか
- メモリ使用量を X MB以内 に抑えたいのか
「何を速くしたいのか」を明確化 しないと、どこまでも終わらない最適化地獄に入ります。
ステップ2:プロファイルしてボトルネックを特定
- DevTools / Nodeのプロファイラ / 独自ログなどで、
- どの関数がどれぐらい時間を食っているか
- どのAPIが遅いか
- メモリがどこで増え続けているか
を可視化する。
体感 よりも 数値 を優先する。
ステップ3:改善の優先順位
- アルゴリズム・データ構造
- O(n²) → O(n log n) や O(n) に落とせないか
- I/Oパターン
- 不必要なAPIアクセスやDBクエリを減らせないか
- まとめて取れないか(N+1問題の解消など)
- 描画・DOM操作の最適化(ブラウザ)
- バッチ更新・仮想リスト・画像lazy loadなど
- 同期処理の分割・Worker化
- 最後にマイクロ最適化
- ループ内での無駄なオブジェクト生成を減らす
- 文字列連結方法の微調整 など(影響が小さいことが多い)
80/20ルール:
全体時間の80%を食っている20%のコードを直すのが一番効く、という感覚を持っておくと良いです。
ステップ4:再計測+リグレッション防止
- 修正前後で、同じ指標をもう一度計測
- 「速くなったと思う」ではなく「◯%改善した」と言える状態にする
- 重要なパフォーマンス指標は、簡易ベンチマークとしてテストに組み込むこともある
(一定以上遅くなったらCIで警告、など)
コメント