はじめに:TTFBとバンドルサイズ、なぜそんなに大事なの?
正直なところ、Webパフォーマンスの話って地味に聞こえるかもしれません。でも2026年の今、TTFB(Time to First Byte)とJavaScriptバンドルサイズを放置したままだと、ユーザー体験もSEOランキングもじわじわ落ちていきます。Googleの調査では全Webサイトの47%がCore Web Vitalsの基準を満たしていないとされていて、その原因の多くがサーバーレスポンスの遅延と、肥大化したJavaScriptバンドルなんですよね。
TTFBはユーザーがURLを入力してからブラウザが最初のバイトを受信するまでの時間で、理想的には0.8秒以下が目標。一方、JavaScriptバンドルサイズはLCP(Largest Contentful Paint)やINP(Interaction to Next Paint)に直接影響します。
この記事では、2026年時点での最新ベストプラクティスと、すぐに使える実践テクニックをまとめて解説していきます。けっこうボリュームがありますが、必要な箇所だけ拾い読みしてもらっても大丈夫です。
第1章:TTFBの基礎と測定方法
TTFBって何?
TTFB(Time to First Byte)は、クライアントがHTTPリクエストを送ってからサーバーがレスポンスの最初のバイトを返すまでの時間です。この指標は3つのフェーズで構成されています:
- DNS解決時間:ドメイン名をIPアドレスに変換する時間
- TCP/TLS接続時間:サーバーとの接続を確立する時間
- サーバー処理時間:リクエストを処理してレスポンスを生成する時間
web.devのガイドラインでは、TTFBの評価基準はこうなっています:
- 良好:0.8秒以下
- 改善が必要:0.8秒〜1.8秒
- 不良:1.8秒以上
個人的な経験では、TTFBが1秒を超えるとユーザーの離脱率が目に見えて上がります。たった0.2秒の差でも、数万PVのサイトだとコンバージョンにかなり効いてくるんですよ。
TTFBの測定ツールと手法
TTFBを正確に測るためのツールはいくつかあります:
- Chrome DevTools:Networkタブの「Timing」セクションで詳細なブレークダウンを確認
- Lighthouse:パフォーマンス監査でTTFBの問題を自動検出
- WebPageTest:複数地域からのTTFB測定とウォーターフォール分析
- Navigation Timing API:プログラムによるリアルユーザーモニタリング(RUM)
まずはNavigation Timing APIを使ったTTFBの測定コードから見ていきましょう。
// Navigation Timing API を使用したTTFB測定
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
// TTFB = responseStart - requestStart
const ttfb = entry.responseStart - entry.requestStart;
console.log(`TTFB: ${ttfb.toFixed(2)}ms`);
// 詳細なブレークダウン
const dns = entry.domainLookupEnd - entry.domainLookupStart;
const tcp = entry.connectEnd - entry.connectStart;
const tls = entry.secureConnectionStart > 0
? entry.connectEnd - entry.secureConnectionStart
: 0;
const serverTime = entry.responseStart - entry.requestStart;
console.log(`DNS: ${dns.toFixed(2)}ms`);
console.log(`TCP: ${tcp.toFixed(2)}ms`);
console.log(`TLS: ${tls.toFixed(2)}ms`);
console.log(`Server: ${serverTime.toFixed(2)}ms`);
});
});
observer.observe({ type: 'navigation', buffered: true });
このコードを本番に仕込んでおくと、実際のユーザー環境でのTTFBが把握できるので、合成テストだけでは見えない問題も発見しやすくなります。
Server Timing APIの活用
Server Timing APIは、サーバー側のパフォーマンスメトリクスをブラウザのDevToolsに伝達してくれる便利な仕組みです。HTTPレスポンスヘッダーにServer-Timingを追加するだけで、サーバー内部のどの処理にどれだけ時間がかかっているかを可視化できます。
これ、意外と知られていないんですが、導入するとデバッグ効率がものすごく上がります。
// Express.js での Server-Timing ヘッダー実装例
app.get('/api/products', async (req, res) => {
const timings = [];
// データベースクエリの計測
const dbStart = performance.now();
const products = await db.query('SELECT * FROM products WHERE active = 1');
const dbDuration = performance.now() - dbStart;
timings.push(`db;dur=${dbDuration.toFixed(2)};desc="Database Query"`);
// キャッシュチェックの計測
const cacheStart = performance.now();
const cached = await redis.get(`products:${req.query.page}`);
const cacheDuration = performance.now() - cacheStart;
timings.push(`cache;dur=${cacheDuration.toFixed(2)};desc="Cache Lookup"`);
// テンプレートレンダリングの計測
const renderStart = performance.now();
const html = renderTemplate('products', { products });
const renderDuration = performance.now() - renderStart;
timings.push(`render;dur=${renderDuration.toFixed(2)};desc="Template Render"`);
res.setHeader('Server-Timing', timings.join(', '));
res.send(html);
});
Chrome DevToolsのNetworkタブでサーバー内部の処理時間が一目で分かるようになるので、ボトルネックの特定が本当に楽になります。
第2章:TTFB削減の実践テクニック
2.1 CDNの戦略的活用
CDN(Content Delivery Network)は、TTFB改善で最も即効性のある手法の一つです。世界中に分散されたPoP(Point of Presence)にコンテンツをキャッシュすることで、ユーザーとの物理的距離を最小化し、ラウンドトリップタイム(RTT)を減らします。
2026年のCDNは、もう単なる静的ファイルのキャッシュだけじゃありません。こんな機能も提供しています:
- 動的コンテンツの高速化:APIレスポンスやパーソナライズされたコンテンツの最適化
- エッジコンピューティング:サーバーレス関数をエッジで実行
- 自動画像最適化:デバイスに応じた画像フォーマットの変換
- HTTP/3対応:QUICプロトコルによる接続高速化
以下は効果的なCDNキャッシュ戦略の設定例です。特にstale-while-revalidateはユーザー体験を損なわずにキャッシュを更新できるので、ぜひ活用してください。
# Nginx でのCDNキャッシュ制御ヘッダー設定
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
# 静的アセットは1年間キャッシュ(バージョニング前提)
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "public, max-age=31536000";
}
location ~* \.html$ {
# HTMLは短めのキャッシュ + stale-while-revalidate
add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400";
add_header CDN-Cache-Control "public, max-age=60";
}
location /api/ {
# APIレスポンスはエッジで短時間キャッシュ
add_header Cache-Control "private, no-cache";
add_header CDN-Cache-Control "public, max-age=10, stale-while-revalidate=30";
}
2.2 エッジSSR(Server-Side Rendering)
2026年、エッジSSRはもはや「新しい技術」ではなく、Web開発のデフォルトのデプロイメントモデルになりつつあります。Cloudflare Workers、Vercel Edge Functions、Deno Deployといったプラットフォームのおかげで、SSRをユーザーの近くで実行できるようになりました。
実際にTTFBが最大300%改善したケースも報告されています。ちょっと信じがたい数字ですが、オリジンサーバーが太平洋を挟んだ向こう側にある場合を考えると、納得がいきます。
- 従来のオリジンSSR:サーバーが1カ所に集中、遠隔地ユーザーのTTFBが増大
- エッジSSR:世界中のエッジロケーションでレンダリング、TTFBを50〜200ms削減
- ストリーミングSSR:HTMLを段階的に配信し、体感速度を最大40%改善
Next.jsでのエッジランタイム設定は驚くほどシンプルです:
// Next.js App Router でのエッジランタイム指定
// app/products/page.tsx
export const runtime = 'edge';
export default async function ProductsPage() {
// エッジで実行されるデータフェッチ
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // 60秒間のISR
});
const products = await res.json();
return (
<main>
<h1>商品一覧</h1>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</main>
);
}
たった1行export const runtime = 'edge';を追加するだけ。これでデプロイ先がエッジに切り替わります。
2.3 ストリーミングSSRの実装
ストリーミングSSRは、HTMLを一度にすべて送るんじゃなく、準備ができた部分から順番にクライアントへ送信する技術です。2026年後半にはデフォルトのSSRモードになると言われていて、従来のSSRと比べて体感ロード時間を最大40%短縮できます。
仕組みとしては、React 18+のSuspenseと組み合わせることで、ヘッダーなどの静的部分はすぐに送り出し、データ取得が必要な部分はスケルトンを表示しつつ後から差し込む、というイメージです。
// React 18+ のストリーミングSSR実装
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
// サーバー側のストリーミングレンダリング
app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App>
<Layout>
{/* ヘッダーは即座にストリーミング */}
<Header />
{/* メインコンテンツはSuspenseでラップ */}
<Suspense fallback={<ProductsSkeleton />}>
<ProductList />
</Suspense>
{/* レコメンドは遅延ロード */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Layout>
</App>,
{
bootstrapScripts: ['/static/client.js'],
onShellReady() {
// シェルが準備できたらストリーミング開始
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.send('<!doctype html><p>Server Error</p>');
},
onError(error) {
console.error('Streaming SSR error:', error);
},
}
);
// タイムアウト処理
setTimeout(() => abort(), 10000);
});
2.4 データベースとバックエンドの最適化
サーバー処理時間はTTFBの中でも大きなウエイトを占めています。ここを改善するだけで、体感速度がガラッと変わることも珍しくありません。
- クエリの最適化:インデックスの適切な設計、N+1問題の解消
- コネクションプーリング:データベース接続の再利用
- アプリケーションレベルキャッシュ:Redis/Memcachedによるデータキャッシュ
- 非同期処理:重い処理のバックグラウンドジョブへの分離
特にRedisを使ったキャッシュレイヤーは、導入のハードルが低い割に効果が大きいのでおすすめです。
// Node.js でのRedisキャッシュレイヤー実装
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
maxRetriesPerRequest: 3,
});
async function getCachedData(key, fetchFn, ttlSeconds = 300) {
// キャッシュヒットの確認
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// キャッシュミス時にデータを取得
const data = await fetchFn();
// バックグラウンドでキャッシュを更新(レスポンスをブロックしない)
redis.setex(key, ttlSeconds, JSON.stringify(data)).catch(console.error);
return data;
}
// 使用例
app.get('/api/products', async (req, res) => {
const products = await getCachedData(
`products:page:${req.query.page || 1}`,
() => db.query('SELECT * FROM products LIMIT 20 OFFSET ?', [offset]),
60 // 60秒キャッシュ
);
res.json(products);
});
第3章:JavaScriptバンドル最適化の基礎
3.1 バンドルサイズが重要な理由
JavaScriptは、Webページのパフォーマンスに最も大きな影響を与えるリソースです。なぜかというと、ダウンロードだけで終わらないからなんですよね。
- ダウンロード:大きなバンドルはネットワーク転送時間を増やす
- パース(解析):ブラウザはダウンロードしたJavaScriptを解析しなきゃいけない
- コンパイル:V8エンジンがバイトコードに変換する
- 実行:コードが走り、メインスレッドをブロックする可能性がある
特にモバイルでは深刻です。CPUパワーが限られているので、同じバンドルサイズでもデスクトップの2〜5倍の処理時間がかかることがあります。2026年ではモバイル検索が全検索の60%以上を占めているわけですから、モバイルのパフォーマンス最適化は「やったほうがいい」じゃなく「やらなきゃマズい」レベルです。
3.2 バンドル分析の方法
最適化の第一歩は、まず現状を正確に把握すること。「なんとなく遅い」だけだと対策の打ちようがないので、以下のツールでバンドルの中身を可視化しましょう。
- webpack-bundle-analyzer:Webpackバンドルのビジュアルなツリーマップ
- rollup-plugin-visualizer:Vite/Rollupプロジェクト向けの分析ツール
- source-map-explorer:ソースマップからバンドル構成を分析
- bundlephobia.com:npmパッケージのサイズをサクッと確認
# webpack-bundle-analyzer のセットアップ
npm install --save-dev webpack-bundle-analyzer
# webpack.config.js に追加
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false,
}),
],
};
# Vite プロジェクトの場合
npm install --save-dev rollup-plugin-visualizer
# vite.config.ts に追加
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'bundle-stats.html',
gzipSize: true,
brotliSize: true,
}),
],
});
ツリーマップを見ると、「えっ、こんなライブラリが入ってたの?」と驚くこともよくあります。まずはここからスタートしてみてください。
第4章:コード分割(Code Splitting)の実践
4.1 ルートベースのコード分割
コード分割は、巨大なJavaScriptバンドルを複数の小さなチャンクに分割し、必要なときだけロードする技術です。もっとも一般的なのはルートベースの分割で、各ページのコードを独立したチャンクとして配信します。
React + React Routerの組み合わせなら、こんな感じで実装できます:
// React Router v6 でのルートベースコード分割
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 動的インポートによる遅延ロード
const HomePage = lazy(() => import('./pages/HomePage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
const AccountPage = lazy(() => import('./pages/AccountPage'));
// ロード中に表示するフォールバック
function PageLoader() {
return (
<div className="page-loader">
<div className="spinner" />
</div>
);
}
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/account/*" element={<AccountPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
4.2 コンポーネントレベルのコード分割
ルートレベルの分割だけで満足しちゃダメです。重いコンポーネントも個別に分割すると、初期ロードがさらに軽くなります。モーダル、チャート、リッチテキストエディタなんかは、ユーザーが操作するまでロードする必要がないですよね。
// コンポーネントレベルの遅延ロード
import { lazy, Suspense, useState } from 'react';
// 重いライブラリを含むコンポーネントを遅延ロード
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const ChartDashboard = lazy(() => import('./components/ChartDashboard'));
const ImageGallery = lazy(() => import('./components/ImageGallery'));
function ProductEditor({ product }) {
const [showChart, setShowChart] = useState(false);
const [showGallery, setShowGallery] = useState(false);
return (
<div>
<h2>{product.name}</h2>
{/* エディタは表示時にのみロード */}
<Suspense fallback={<div>エディタを読み込み中...</div>}>
<RichTextEditor
content={product.description}
onChange={handleDescriptionChange}
/>
</Suspense>
{/* チャートはボタンクリック時にのみロード */}
<button onClick={() => setShowChart(true)}>
売上チャートを表示
</button>
{showChart && (
<Suspense fallback={<div>チャートを読み込み中...</div>}>
<ChartDashboard productId={product.id} />
</Suspense>
)}
</div>
);
}
4.3 プリフェッチでUXを改善する
コード分割の唯一のデメリットは、ナビゲーション時にチャンクのロード待ちが発生すること。でも、ユーザーの行動を予測してチャンクを事前にロードしておけば、この問題はほぼ解消できます。
たとえば、リンクにマウスを乗せた時点でプリフェッチを開始するだけで、クリック時にはもうチャンクが読み込まれている、なんてことも可能です。
// リンクホバー時のプリフェッチ実装
import { lazy, Suspense, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
// モジュール参照を保持(プリフェッチ用)
const importProductPage = () => import('./pages/ProductPage');
const ProductPage = lazy(importProductPage);
function ProductLink({ product }) {
const prefetch = useCallback(() => {
// ホバー時にチャンクをプリフェッチ
importProductPage();
}, []);
return (
<Link
to={`/products/${product.id}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{product.name}
</Link>
);
}
// Intersection Observer を使った自動プリフェッチ
function AutoPrefetchLink({ to, importFn, children }) {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
importFn();
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [importFn]);
return (
<Link ref={ref} to={to}>
{children}
</Link>
);
}
第5章:Tree Shaking(ツリーシェイキング)の最適化
5.1 Tree Shakingの仕組み
Tree Shakingは、ビルド時にデッドコード(使われていないコード)を自動的に除去する技術です。ES6モジュール構文の静的な構造を利用して、バンドラーが「このコードは実際に使われているか?」を判定してくれます。
ただし、Tree Shakingが正しく機能するにはいくつか条件があります:
- ES6モジュール構文(
import/export)を一貫して使う - CommonJS(
require/module.exports)を避ける - サイドエフェクトのないモジュールを
package.jsonで宣言する - バンドラーの最適化設定を正しく構成する
// package.json での sideEffects 宣言
{
"name": "my-library",
"version": "1.0.0",
"sideEffects": false,
// または、サイドエフェクトのあるファイルを明示
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}
// webpack.config.js での Tree Shaking 設定
module.exports = {
mode: 'production', // production モードでTree Shakingが有効
optimization: {
usedExports: true, // 使用されているエクスポートをマーク
minimize: true, // Terser による最小化
concatenateModules: true, // モジュール連結(スコープホイスティング)
},
};
5.2 ライブラリの正しいインポート方法
ここ、めちゃくちゃ重要です。多くの人気ライブラリは、インポートの仕方を間違えるとTree Shakingが効かず、バンドルが一気に膨らみます。
// ❌ 悪い例:ライブラリ全体をインポート
import _ from 'lodash'; // ~70KB gzip
import * as Icons from 'lucide-react'; // アイコン全体をバンドル
import moment from 'moment'; // ~67KB gzip(ロケール含む)
// ✅ 良い例:必要な関数のみをインポート
import debounce from 'lodash/debounce'; // ~1KB gzip
import { Search, Menu } from 'lucide-react'; // 使用するアイコンのみ
import { format } from 'date-fns'; // ~2KB gzip(必要な関数のみ)
// ✅ さらに良い例:軽量な代替ライブラリの使用
// lodash → lodash-es(ES Module版でTree Shaking対応)
import { debounce } from 'lodash-es';
// moment.js → dayjs(2KB vs 67KB)
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
lodashを丸ごとインポートして70KBとか、正直もったいなさすぎます。debounce1つに1KBで済むのに。
5.3 バレルファイルの落とし穴
バレルファイル(index.tsで再エクスポートをまとめるパターン)、便利なんですけど実はTree Shakingを阻害する意外な原因になります。
// ❌ バレルファイルの問題
// components/index.ts
export { Button } from './Button';
export { Modal } from './Modal'; // 重いコンポーネント
export { DataGrid } from './DataGrid'; // 非常に重いコンポーネント
export { Chart } from './Chart'; // Chart.js依存
// 使用側:Buttonだけ必要だが、全コンポーネントがバンドルされる可能性
import { Button } from './components';
// ✅ 直接インポートで解決
import { Button } from './components/Button';
// ✅ または、Vite/Webpack の設定で対処
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
// バレルファイルを迂回するエイリアス
'@components': path.resolve(__dirname, 'src/components'),
},
},
});
Buttonだけ使いたいのにChart.jsまで巻き込まれる...なんて状況、想像するだけでゾッとしますよね。
第6章:モダンバンドラーの活用(Vite vs Webpack)
6.1 Viteによる高速ビルドと最適化
2026年、Viteはフロントエンドビルドツールのデファクトスタンダードと言っていい存在になりました。内部でRollupを使ったプロダクションビルドは、Webpackと比べて平均約19.5%小さいバンドルを生成でき、ビルド速度も段違いです。
以下は、本番向けの最適化設定例です。コメントで各オプションの意味を書いておいたので、プロジェクトに合わせて調整してみてください。
// vite.config.ts の最適化設定
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// チャンクサイズの警告閾値
chunkSizeWarningLimit: 500, // 500KB
rollupOptions: {
output: {
// ベンダーライブラリの分割戦略
manualChunks(id) {
if (id.includes('node_modules')) {
// React関連をまとめる
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react';
}
// UIライブラリを分離
if (id.includes('@radix-ui') || id.includes('class-variance-authority')) {
return 'vendor-ui';
}
// ユーティリティを分離
if (id.includes('date-fns') || id.includes('lodash-es')) {
return 'vendor-utils';
}
// その他のnode_modulesをまとめる
return 'vendor';
}
},
},
},
// ターゲットブラウザの指定(不要なポリフィルを除去)
target: 'es2022',
// CSS のコード分割を有効化
cssCodeSplit: true,
// ソースマップの設定(本番では hidden を推奨)
sourcemap: 'hidden',
// 圧縮設定
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // console.log を除去
drop_debugger: true, // debugger 文を除去
pure_funcs: ['console.info', 'console.debug'],
},
},
},
});
6.2 Webpackの高度な最適化
「うちはまだWebpack使ってるんだけど...」という方も安心してください。適切な設定をすれば、Webpackでも十分なバンドルサイズ削減が可能です。
// webpack.config.js の高度な最適化設定
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 20,
maxAsyncRequests: 20,
cacheGroups: {
// フレームワークを分離(長期キャッシュに最適)
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// 大きなライブラリを個別チャンクに
largeVendors: {
test: /[\\/]node_modules[\\/](chart\.js|moment|lodash)[\\/]/,
name(module) {
const match = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
);
return `vendor-${match[1].replace('@', '')}`;
},
priority: 30,
},
// 共通モジュール
commons: {
minChunks: 2,
priority: 20,
reuseExistingChunk: true,
},
},
},
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: { passes: 2 },
},
}),
new CssMinimizerPlugin(),
],
},
plugins: [
// Brotli圧縮
new CompressionPlugin({
algorithm: 'brotliCompress',
filename: '[path][base].br',
test: /\.(js|css|html|svg)$/,
threshold: 1024,
minRatio: 0.8,
}),
],
};
第7章:高度な最適化テクニック
7.1 モジュールフェデレーション
マイクロフロントエンドをやっているチームなら、Webpackのモジュールフェデレーションは要チェックです。異なるアプリケーション間でモジュールを動的に共有できるので、共通ライブラリ(Reactとか)の重複ロードを防ぎ、全体のバンドルサイズを削減できます。
// webpack.config.js - ホストアプリケーション
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
// リモートアプリケーションのモジュールを参照
productApp: 'productApp@https://products.example.com/remoteEntry.js',
cartApp: 'cartApp@https://cart.example.com/remoteEntry.js',
},
shared: {
// 共有ライブラリの設定
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
7.2 Web Workerでメインスレッドを解放する
重い計算処理をWeb Workerに逃がすと、メインスレッドがブロックされなくなり、INPスコアが改善します。CSVのパースとか分析処理みたいな、CPUを食う作業にはかなり効果的です。
// worker.ts - Web Worker
self.addEventListener('message', async (event) => {
const { type, payload } = event.data;
switch (type) {
case 'PARSE_CSV': {
const rows = payload.split('\n').map(row => {
const cols = row.split(',');
return {
id: cols[0],
name: cols[1],
price: parseFloat(cols[2]),
quantity: parseInt(cols[3], 10),
};
});
self.postMessage({ type: 'CSV_PARSED', data: rows });
break;
}
case 'CALCULATE_ANALYTICS': {
const result = computeHeavyAnalytics(payload);
self.postMessage({ type: 'ANALYTICS_READY', data: result });
break;
}
}
});
// main.ts - メインスレッド側
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);
function processCSVInBackground(csvData) {
return new Promise((resolve) => {
worker.addEventListener('message', function handler(event) {
if (event.data.type === 'CSV_PARSED') {
worker.removeEventListener('message', handler);
resolve(event.data.data);
}
});
worker.postMessage({ type: 'PARSE_CSV', payload: csvData });
});
}
7.3 リソースヒントを使いこなす
ブラウザのリソースヒントを正しく活用すると、重要なリソースのロード優先度をコントロールでき、TTFBとLCPの両方を改善できます。地味ですが、HTMLの<head>に数行追加するだけで効果が出るので、コスパは最高です。
<!-- DNS プリフェッチ:サードパーティドメインの DNS 解決を事前に実行 -->
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- プリコネクト:TCP/TLS 接続まで事前に確立 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- プリロード:重要なリソースを高優先度でロード -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/hero-image.avif" as="image" fetchpriority="high">
<!-- fetchpriority によるリソース優先度制御 -->
<img src="/hero.avif" alt="Hero" fetchpriority="high">
<img src="/below-fold.avif" alt="Below fold" fetchpriority="low" loading="lazy">
<!-- スクリプトの最適なロード戦略 -->
<script src="/critical-app.js" type="module"></script>
<script src="/analytics.js" defer></script>
<script src="/non-critical.js" async></script>
第8章:パフォーマンスバジェットの設定と監視
8.1 パフォーマンスバジェットとは
パフォーマンスバジェットは、Webサイトのパフォーマンス指標に「ここまでならOK」という上限値を設け、超えたらアラートやビルド失敗を出す仕組みです。チーム全体で基準を共有し、誰かが巨大なライブラリをうっかり追加しても気づけるようにしておくのが目的です。
推奨されるバジェットの目安:
- 初期JavaScriptバンドル:200KB以下(gzip後)
- 合計JavaScript:500KB以下(gzip後)
- CSSバンドル:100KB以下
- LCP:2.5秒以下
- TTFB:0.8秒以下
- CLS:0.1以下
- INP:200ms以下
8.2 CI/CDパイプラインでの自動チェック
手動チェックだと絶対に忘れるので(経験済み)、CI/CDに組み込んでしまうのがベストです。
// bundlesize.config.json - バンドルサイズの自動チェック
{
"files": [
{
"path": "dist/assets/index-*.js",
"maxSize": "200 kB",
"compression": "gzip"
},
{
"path": "dist/assets/vendor-react-*.js",
"maxSize": "50 kB",
"compression": "gzip"
},
{
"path": "dist/assets/vendor-*.js",
"maxSize": "150 kB",
"compression": "gzip"
},
{
"path": "dist/assets/*.css",
"maxSize": "30 kB",
"compression": "gzip"
}
]
}
# GitHub Actions でのパフォーマンスバジェットチェック
# .github/workflows/performance.yml
name: Performance Budget Check
on:
pull_request:
branches: [main]
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
# バンドルサイズチェック
- name: Check bundle size
run: npx bundlesize
# Lighthouse CI
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v12
with:
configPath: ./lighthouserc.json
uploadArtifacts: true
PRごとにバンドルサイズがチェックされるようになれば、パフォーマンス劣化を未然に防げます。
8.3 リアルユーザーモニタリング(RUM)
Lighthouseのスコアが良くても、実際のユーザーが快適かどうかは別問題です。合成テストはラボ環境での結果にすぎないので、本当に大事なのは実ユーザーのデータです。
// Web Vitals ライブラリを使用したRUM実装
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
// ページ情報
url: window.location.href,
userAgent: navigator.userAgent,
connectionType: navigator.connection?.effectiveType || 'unknown',
timestamp: Date.now(),
});
// sendBeacon で確実に送信(ページ離脱時も送信される)
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/web-vitals', body);
} else {
fetch('/api/web-vitals', {
method: 'POST',
body,
keepalive: true,
});
}
}
// 全Core Web Vitalsの測定を開始
onTTFB(sendToAnalytics);
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
第9章:実践的な最適化チェックリスト
ここまで読んでくれた方のために、すべてを整理したチェックリストを用意しました。優先度順に並べているので、上から順に取り組むのがおすすめです。
TTFB最適化チェックリスト
- CDNの導入と適切なキャッシュヘッダーの設定:静的アセットに
immutable、HTMLにstale-while-revalidate - HTTP/3(QUIC)の有効化:接続確立時間の大幅な削減
- エッジSSRの検討:動的コンテンツをユーザーの近くでレンダリング
- ストリーミングSSRの実装:React 18+のSuspenseを活用した段階的配信
- データベースクエリの最適化:インデックス設計、N+1問題の解消
- アプリケーションキャッシュ:Redis/Memcachedによるデータキャッシュ
- Server Timing APIの導入:サーバー側のボトルネックを可視化
- DNSプリフェッチとプリコネクト:サードパーティリソースへの接続を事前に確立
バンドル最適化チェックリスト
- バンドル分析の実施:webpack-bundle-analyzerまたはrollup-plugin-visualizerで現状把握
- ルートベースのコード分割:React.lazyとSuspenseによるページ単位の分割
- ライブラリの最適なインポート:名前付きインポートでTree Shakingを活用
- 軽量な代替ライブラリへの移行:moment.js → dayjs、lodash → lodash-es
- バレルファイルの見直し:直接インポートに切り替え
- ビルドターゲットの最適化:ES2022以上に設定し、不要なポリフィルを除去
- パフォーマンスバジェットの設定:CI/CDで自動チェック
- 圧縮の最適化:Brotli圧縮の有効化(gzipより20〜30%効率的)
- Web Workerの活用:重い計算処理をメインスレッドから分離
- RUMの導入:web-vitalsライブラリで継続的にパフォーマンスを監視
まとめ
TTFBの削減とJavaScriptバンドルの最適化は、2026年のWebパフォーマンス戦略で避けて通れないテーマです。エッジSSRやストリーミングSSRが普及したおかげで、サーバーレスポンスタイムは劇的に改善できるようになりました。そして、コード分割やTree Shaking、モダンバンドラーの適切な設定で、クライアントに送るJavaScriptの量も大幅に減らせます。
ただ、一番大事なのは「一度やって終わり」にしないことです。パフォーマンスバジェットとRUMを組み合わせた継続的なモニタリング体制を構築して、CI/CDパイプラインにチェックを組み込み、実際のユーザーデータに基づいて改善を回し続ける。それが、Core Web Vitalsの全指標を「良好」に保ち続ける唯一の方法です。
この記事で紹介したテクニックを一度に全部やる必要はありません。まずはバンドル分析とCDN設定から始めて、計測→改善のサイクルを少しずつ回していきましょう。それがWebパフォーマンス最適化の、一番確実な近道です。