performance-engineering について
performance-engineeringは、Webアプリ開発者向けのパフォーマンス測定・最適化ガイドです。Core Web Vitals、コード分割、キャッシュ、データベースクエリ、負荷テストを扱い、測定に基づく改善を重視します。
主な機能
- 01Core Web Vitalsの測定
- 02JavaScriptバンドル分析とコード分割
- 03画像とクリティカルパスの最適化
- 04ブラウザー、サーバー、CDNのキャッシュ設定
- 05データベースクエリ性能の分析
- 06k6とArtilleryによる負荷テスト
活用シーン
- 01Core Web Vitalsの目標未達の調査
- 02増加するフロントエンドバンドルの縮小
- 03ページ表示と操作応答の改善
- 04負荷テストによるトラフィック急増への準備
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill performance-engineeringインストール後、Claude Code で/performance-engineeringを入力して読み込まれたか確認します。
詳細
パフォーマンスエンジニアリング
概要
このスキルでは、フルスタックにわたるウェブアプリケーションのパフォーマンスの測定、最適化、維持について扱います。Core Web Vitals(LCP、INP、CLS)、バンドルの分析とコード分割、画像の最適化、ブラウザーおよびサーバーサイドのキャッシュ、CDN の設定、データベースクエリのパフォーマンス、k6 と Artillery による負荷テストを対象とします。
Core Web Vitals のスコアが目標を下回る場合、バンドルサイズが増加している場合、ページの読み込み時間が悪化している場合、またはトラフィックの急増に備える場合に、このスキルを使用してください。パフォーマンスの改善は測定に基づいて行う必要があります。まずプロファイルを取り、次に最適化し、最後に効果を測定してください。
基本原則
- 最適化の前に測定する - 最適化コードを書く前に、実データ(RUM、Lighthouse、DevTools)を使ってプロファイルを取ってください。パフォーマンスのボトルネックに関する直感は、たいてい間違っています。
- すべてに予算を設定する - バンドルサイズ(< 200kb JS)、LCP(< 2.5s)、INP(< 200ms)、CLS(< 0.1)のパフォーマンス予算を設定してください。レビューでは、変更内容が予算内か確認してください。
- クリティカルパスを最適化する - ユーザーがコンテンツを表示し、操作できるようになるまでの妨げとなる要素に集中してください。それ以外は後から読み込めます。
- 積極的にキャッシュし、正確に無効化する - 静的アセットには不変のハッシュを、動的コンテンツには短い TTL を使い、両方の利点を得るには stale-while-revalidate を使ってください。
- 現実的な規模を想定する - 同時接続ユーザーが 10 人の小規模なデータセットではなく、本番環境に近いデータ量とトラフィックを基準に設計を評価してください。負荷テストはユーザーから依頼された場合にのみ実施してください。
主なパターン
パターン 1:Core Web Vitals のモニタリング
使用する場面: すべての本番ウェブアプリケーション。これらの指標は SEO ランキングとユーザーエクスペリエンスに直接影響します。
実装:
// Real User Monitoring (RUM) with web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from "web-vitals";
interface VitalMetric {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
navigationType: string;
}
function sendToAnalytics(metric: VitalMetric) {
// Send to your analytics endpoint
navigator.sendBeacon("/api/vitals", JSON.stringify({
...metric,
url: window.location.href,
userAgent: navigator.userAgent,
connectionType: (navigator as unknown as { connection?: { effectiveType: string } })
.connection?.effectiveType ?? "unknown",
timestamp: Date.now(),
}));
}
// Capture all Core Web Vitals
onLCP((metric) => sendToAnalytics({
name: "LCP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onINP((metric) => sendToAnalytics({
name: "INP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onCLS((metric) => sendToAnalytics({
name: "CLS",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onFCP((metric) => sendToAnalytics({
name: "FCP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onTTFB((metric) => sendToAnalytics({
name: "TTFB",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
Lighthouse CI やその他の新しい CI ジョブは、ユーザーから依頼された場合にのみ追加してください。
// Lighthouse CI configuration
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
"http://localhost:3000/",
"http://localhost:3000/dashboard",
"http://localhost:3000/pricing",
],
numberOfRuns: 3,
},
assert: {
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["error", { minScore: 0.95 }],
"first-contentful-paint": ["warn", { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"interactive": ["error", { maxNumericValue: 3800 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-byte-weight": ["warn", { maxNumericValue: 500000 }],
},
},
upload: {
target: "temporary-public-storage",
},
},
};
理由: ラボ指標(Lighthouse)はデプロイ前にリグレッションを検出します。フィールド指標(RUM)は、さまざまなデバイスやネットワークにおける実際のユーザーエクスペリエンスを示します。どちらも必要です。ラボ指標は防止に、フィールド指標は実態の把握に役立ちます。
パターン 2:バンドルの最適化とコード分割
使用する場面: JavaScript バンドルサイズが gzip 圧縮後に 200kb を超える場合、または初期読み込みにユーザーがまだ訪問していないルートのコードが含まれている場合。
実装:
// Next.js - Dynamic imports for route-based code splitting
import dynamic from "next/dynamic";
// Heavy component loaded only when needed
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
loading: () => <div className="editor-skeleton" aria-busy="true">Loading editor...</div>,
ssr: false, // Client-only component
});
const ChartDashboard = dynamic(() => import("@/components/ChartDashboard"), {
loading: () => <ChartSkeleton />,
});
// Conditional feature loading
function ProjectPage({ project }: { project: Project }) {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<h1>{project.name}</h1>
<button onClick={() => setShowEditor(true)}>Edit Description</button>
{showEditor && <RichTextEditor content={project.description} />}
</div>
);
}
// webpack-bundle-analyzer integration
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
// Analyze specific packages for tree-shaking
experimental: {
optimizePackageImports: ["lodash-es", "date-fns", "@mui/material", "lucide-react"],
},
});
# Analyze bundle
ANALYZE=true npm run build
# Check specific import costs
npx import-cost # VS Code extension alternative
npx source-map-explorer .next/static/chunks/*.js
理由: JavaScript は 1 キロバイト増えるごとに、ユーザーのデバイスでの解析時間、コンパイル時間、実行時間が増えます。コード分割によって、ユーザーが現在表示している画面に必要なコードだけをダウンロードするようにできます。エディター、チャート、マップなどの重いコンポーネントを遅延読み込みすれば、初期バンドルを軽量に保てます。
パターン 3:画像の最適化
使用する場面: 画像があるページ(ほとんどのページが該当します)。通常、画像はページ上で最も容量の大きいアセットです。
実装:
// Next.js Image component with proper optimization
import Image from "next/image";
// Responsive hero image
function HeroSection() {
return (
<section>
<Image
src="/hero.jpg"
alt="Team collaborating on a project"
width={1920}
height={1080}
priority // LCP element - skip lazy loading
sizes="100vw"
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQ..." // Low-quality placeholder
/>
</section>
);
}
// Responsive card image
function ProductCard({ product }: { product: Product }) {
return (
<article>
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy" // Below the fold
/>
<h3>{product.name}</h3>
</article>
);
}
// Sharp-based image processing pipeline for user uploads
import sharp from "sharp";
interface ImageVariant {
width: number;
suffix: string;
quality: number;
}
const variants: ImageVariant[] = [
{ width: 320, suffix: "sm", quality: 80 },
{ width: 640, suffix: "md", quality: 80 },
{ width: 1280, suffix: "lg", quality: 85 },
{ width: 1920, suffix: "xl", quality: 85 },
];
async function processUploadedImage(
buffer: Buffer,
filename: string
): Promise<string[]> {
const urls: string[] = [];
for (const variant of variants) {
// Generate WebP (best compression)
const webp = await sharp(buffer)
.resize(variant.width, null, { withoutEnlargement: true })
.webp({ quality: variant.quality })
.toBuffer();
// Generate AVIF (even better compression, slower to encode)
const avif = await sharp(buffer)
.resize(variant.width, null, { withoutEnlargement: true })
.avif({ quality: variant.quality - 10 })
.toBuffer();
const webpUrl = await uploadToStorage(webp, `${filename}-${variant.suffix}.webp`);
const avifUrl = await uploadToStorage(avif, `${filename}-${variant.suffix}.avif`);
urls.push(webpUrl, avifUrl);
}
return urls;
}
理由: ほとんどのサイトでは、画像がページ容量の 50% 以上を占めます。最新のフォーマット(WebP、AVIF)は、JPEG と比べてサイズを 25〜50% 削減できます。レスポンシブな sizes 属性を指定すると、モバイルデバイスがデスクトップ向けサイズの画像をダウンロードするのを防げます。LCP 画像に priority を指定すると、最も重要な視覚要素の遅延読み込みがなくなります。
パターン 4:キャッシュ戦略
使用する場面: どのアプリケーションでもキャッシュの恩恵を受けられます。重要なのは、どのレイヤーで、どの TTL を設定するかです。
実装:
// HTTP cache headers for different asset types
// next.config.js
module.exports = {
async headers() {
return [
// Static assets with content hashes - cache forever
{
source: "/_next/static/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
// API responses - short cache with revalidation
{
source: "/api/:path*",
headers: [
{
key: "Cache-Control",
value: "public, s-maxage=60, stale-while-revalidate=300",
},
],
},
// HTML pages - always revalidate
{
source: "/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=0, must-revalidate",
},
],
},
];
},
};
// Server-side caching with Redis
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
interface CacheOptions {
ttlSeconds: number;
staleSeconds?: number; // Serve stale while revalidating
}
async function cached<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions
): Promise<T> {
const cached = await redis.get(key);
if (cached) {
const { data, expiry } = JSON.parse(cached);
const isStale = Date.now() > expiry;
if (!isStale) {
return data as T;
}
// Stale-while-revalidate: return stale data, refresh in background
if (options.staleSeconds) {
const staleDeadline = expiry + options.staleSeconds * 1000;
if (Date.now() < staleDeadline) {
// Revalidate in background (fire-and-forget)
refreshCache(key, fetcher, options).catch(console.error);
return data as T;
}
}
}
return refreshCache(key, fetcher, options);
}
async function refreshCache<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions
): Promise<T> {
const data = await fetcher();
const expiry = Date.now() + options.ttlSeconds * 1000;
const totalTtl = options.ttlSeconds + (options.staleSeconds ?? 0);
await redis.setex(key, totalTtl, JSON.stringify({ data, expiry }));
return data;
}
// Usage
const products = await cached(
`products:category:${categoryId}`,
() => db.products.findMany({ where: { categoryId } }),
{ ttlSeconds: 300, staleSeconds: 600 }
);
理由: キャッシュを使うと、重複した計算やネットワークリクエストをなくせます。コンテンツハッシュを持つ不変の静的アセットは、安全に無期限でキャッシュできます。stale-while-revalidate を使えば、ユーザーには即座に応答を返しながら、バックグラウンドでデータを最新に保てるため、体感パフォーマンスを最大限に高められます。
パターン 5:k6 による負荷テスト
使用する場面: 大規模なリリースや移行の前、またはパフォーマンスのベースラインを確立するためなど、ユーザーから負荷テストを依頼された場合です。
実装:
// load-test.js - k6 load test script
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";
const errorRate = new Rate("errors");
const apiDuration = new Trend("api_duration", true);
export const options = {
stages: [
{ duration: "2m", target: 50 }, // Ramp up
{ duration: "5m", target: 50 }, // Steady state
{ duration: "2m", target: 200 }, // Spike
{ duration: "5m", target: 200 }, // Sustained spike
{ duration: "2m", target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"], // 95th < 500ms, 99th < 1s
errors: ["rate<0.01"], // Error rate < 1%
api_duration: ["avg<200"], // Average API < 200ms
},
};
const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
export default function () {
// Simulate realistic user journey
const homeRes = http.get(`${BASE_URL}/`);
check(homeRes, {
"home status 200": (r) => r.status === 200,
"home load < 2s": (r) => r.timings.duration < 2000,
});
errorRate.add(homeRes.status >= 400);
sleep(1);
// API call
const apiRes = http.get(`${BASE_URL}/api/products?category=electronics`, {
headers: { "Content-Type": "application/json" },
});
check(apiRes, {
"api status 200": (r) => r.status === 200,
"api has results": (r) => JSON.parse(r.body).length > 0,
});
apiDuration.add(apiRes.timings.duration);
errorRate.add(apiRes.status >= 400);
sleep(Math.random() * 3); // Random think time
}
# Run load test
k6 run load-test.js
# Run with custom base URL
k6 run -e BASE_URL=https://staging.example.com load-test.js
# Output results to JSON for analysis
k6 run --out json=results.json load-test.js
理由: 負荷テストでは、同時実行時にのみ発生するボトルネックを特定できます。たとえば、データベース接続プールの枯渇、メモリーリーク、ロック競合、連鎖的なタイムアウトなどです。現実的なトラフィックパターン(段階的な負荷の増加、持続的な負荷、急増)でテストすることで、システムが実環境の条件に対応できることを確認できます。
パフォーマンス予算の参考値
| 指標 | 良好 | 要改善 | 不良 |
|---|---|---|---|
| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP | < 200ms | 200ms - 500ms | > 500ms |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 |
| FCP | < 1.8s | 1.8s - 3.0s | > 3.0s |
| TTFB | < 800ms | 800ms - 1800ms | > 1800ms |
| JS 合計 | < 200kb gz | 200-400kb gz | > 400kb gz |
| ページの合計サイズ | < 1MB | 1-3MB | > 3MB |
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
| 計測せずに最適化する | ボトルネックではない箇所に労力を費やします | まずプロファイリングし、計測で特定したホットスポットを最適化します |
| すべての JS を最初に読み込む | 使用されないコードによって操作性が損なわれます | ルートごとにコードを分割し、重いコンポーネントは遅延読み込みします |
width/height を指定せずに画像を使用する | レイアウトシフト(CLS)が発生します | 必ず寸法を指定するか、アスペクト比を使用します |
すべてに Cache-Control: no-cache を指定する | リクエストのたびにオリジンサーバーへアクセスします | API には stale-while-revalidate を、ハッシュ付きアセットには immutable を使用します |
| Web フォントを同期的に読み込む | テキストの描画が妨げられます(FOIT) | font-display: swap または optional を使用します |
<head> 内にサードパーティスクリプトを置く | ページの描画が妨げられます | async または defer を指定して読み込むか、操作後に読み込みます |
| N+1 データベースクエリ | データ量の増加に伴ってレイテンシが増大します | イーガーローディング(Prisma の include)やクエリのバッチ処理を使用します |
チェックリスト
- フィールド(RUM)とラボ(Lighthouse)で Core Web Vitals を計測している
- パフォーマンス予算を定義し、レビューで確認している
- バンドルを分析し、ルートごとにコードを分割している
- 画像を最適化している(WebP/AVIF、レスポンシブサイズ、遅延読み込み)
- LCP 画像に
priority/fetchpriority="high"を指定している - アセットの種類ごとにキャッシュヘッダーを設定している
- 高コストなクエリにサーバーサイドキャッシュを使用している(Redis またはインメモリー)
- サードパーティスクリプトを監査し、非同期で読み込んでいる
- データベースクエリを分析している(N+1 がなく、適切なインデックスがある)
- 現実的なトラフィックに対する負荷時の挙動を検討している(負荷テストは依頼があった場合のみ)
- カスタム Web フォントに
font-display: swapを設定している
関連リソース
- スキル:
monitoring-observability(パフォーマンス監視)、serverless-development(コールドスタートの最適化) - ルール:
docs/reference/checklists/ui-visual-changes.md(視覚的なパフォーマンス) - ルール:
docs/reference/stacks/react-typescript.md(React のパフォーマンスパターン)