パフォーマンス
ユーザー離脱率50%削減した実際のWebパフォーマンス最適化事例
カービー
#パフォーマンス最適化#Core Web Vitals#実体験#ECサイト#UX改善
ECサイトのCore Web Vitals改善で離脱率を50%削減した実際のプロジェクト事例。具体的な改善手法と効果測定の詳細を公開。
目次
- プロジェクト背景
- 問題の特定と分析
- LCP改善: 画像最適化の取り組み
- CLS改善: レイアウトシフト解決
- FID改善: JavaScript最適化
- 実装した監視体制
- ビジネスインパクト
- 失敗から学んだ教訓
プロジェクト背景
2025年春、アパレルECサイトのパフォーマンス改善プロジェクトを担当しました。
サイト概要
- 業界: アパレル・ファッション
- 月間PV: 約50万PV
- 主要ユーザー: 20-40代女性(モバイル利用率85%)
- 商品点数: 約8,000点
- 平均画像数/ページ: 15-20枚
プロジェクト目標
KPI改善目標:
- Core Web Vitals: Good評価達成
- モバイル離脱率: 65% → 32%以下
- コンバージョン率: 2.1% → 3.5%以上
- ページ滞在時間: 1分20秒 → 2分以上
問題の特定と分析
初期状態の問題点
Core Web Vitals スコア(改善前)
モバイル:
├── LCP: 6.8秒 (Poor)
├── FID: 420ms (Poor)
└── CLS: 0.35 (Poor)
デスクトップ:
├── LCP: 3.2秒 (Needs Improvement)
├── FID: 180ms (Needs Improvement)
└── CLS: 0.18 (Needs Improvement)
詳細分析で見つかった原因
1. 画像関連の問題
// 問題のあるコード例
<img src="product-image-original.jpg" alt="商品画像">
// 4MB の高解像度画像がそのまま配信
// WebP未対応
// 遅延読み込み未実装
2. レイアウトシフトの原因
/* 問題のあるCSS */
.product-image {
/* width/heightの明示なし */
}
.banner-area {
/* 高さが動的に決まる広告 */
}
3. JavaScript実行の問題
// 問題のあるJS読み込み
<script src="jquery.min.js"></script>
<script src="bootstrap.min.js"></script>
<script src="slick-carousel.min.js"></script>
// 合計280KB のJSがメインスレッドをブロック
LCP改善: 画像最適化の取り組み
アプローチ1: 次世代画像フォーマット対応
// 改善後: レスポンシブ画像 + WebP対応
function generatePictureElement(src, alt, sizes) {
return `
<picture>
<source
srcset="
${src.replace('.jpg', '-400w.webp')} 400w,
${src.replace('.jpg', '-800w.webp')} 800w,
${src.replace('.jpg', '-1200w.webp')} 1200w
"
sizes="${sizes}"
type="image/webp">
<source
srcset="
${src.replace('.jpg', '-400w.jpg')} 400w,
${src.replace('.jpg', '-800w.jpg')} 800w,
${src.replace('.jpg', '-1200w.jpg')} 1200w
"
sizes="${sizes}"
type="image/jpeg">
<img
src="${src.replace('.jpg', '-800w.jpg')}"
alt="${alt}"
width="800"
height="600"
loading="lazy"
decoding="async">
</picture>
`;
}
// 使用例
const productImage = generatePictureElement(
'product-dress-001.jpg',
'おしゃれなワンピース',
'(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
);
画像最適化の詳細データ
最適化結果:
├── 元画像: 4.2MB → WebP: 180KB (96%削減)
├── レスポンシブ対応: デバイス別最適サイズ配信
├── 遅延読み込み: 初期表示3秒 → 0.8秒
└── CDN配信: CloudFront導入でグローバル高速化
アプローチ2: Critical Resource Hints
<!-- 重要リソースの事前読み込み -->
<link rel="preload" as="image" href="hero-banner-mobile.webp"
media="(max-width: 768px)">
<link rel="preload" as="image" href="hero-banner-desktop.webp"
media="(min-width: 769px)">
<!-- DNS prefetch -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<!-- Preconnect for critical third-party origins -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
CLS改善: レイアウトシフト解決
問題1: 画像サイズ未指定によるシフト
/* 改善前の問題あるCSS */
.product-image img {
width: 100%;
height: auto; /* 高さが後から決まる = シフト発生 */
}
/* 改善後: アスペクト比を事前定義 */
.product-image {
position: relative;
width: 100%;
aspect-ratio: 4/3; /* アスペクト比を事前定義 */
overflow: hidden;
}
.product-image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
問題2: 動的コンテンツによるシフト
// 改善前: コンテンツ取得後にDOMに追加(シフト発生)
fetch('/api/user-reviews')
.then(response => response.json())
.then(data => {
const reviewsHtml = data.map(review => `
<div class="review-item">${review.content}</div>
`).join('');
document.getElementById('reviews').innerHTML = reviewsHtml;
// ここでレイアウトシフト発生
});
// 改善後: プレースホルダーで領域確保
class ReviewLoader {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.createPlaceholder();
this.loadReviews();
}
createPlaceholder() {
// レビューの平均的なサイズでプレースホルダー作成
const placeholder = `
<div class="review-placeholder">
${Array(5).fill(0).map(() => `
<div class="review-skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
`).join('')}
</div>
`;
this.container.innerHTML = placeholder;
}
async loadReviews() {
try {
const response = await fetch('/api/user-reviews');
const reviews = await response.json();
// 同じ高さを維持しながらコンテンツ置換
this.container.innerHTML = reviews.map(review => `
<div class="review-item" style="min-height: 120px;">
<div class="review-content">${review.content}</div>
<div class="review-meta">
<span class="reviewer">${review.name}</span>
<span class="review-date">${review.date}</span>
</div>
</div>
`).join('');
} catch (error) {
console.error('Reviews load error:', error);
}
}
}
// スケルトンスクリーンのCSS
.skeleton-avatar {
width: 40px;
height: 40px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 50%;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
CLS改善結果
レイアウトシフト改善:
├── 商品画像: CLS 0.23 → 0.02 (91%改善)
├── レビューセクション: CLS 0.15 → 0.01 (93%改善)
├── 広告枠: CLS 0.08 → 0.00 (100%改善)
└── 総合CLS: 0.35 → 0.05 (86%改善)
FID改善: JavaScript最適化
アプローチ1: コード分割とTree Shaking
// 改善前: すべてのJSを一括読み込み
import jQuery from 'jquery';
import 'bootstrap';
import 'slick-carousel';
// 280KB のバンドル
// 改善後: 必要な機能のみ動的インポート
class ProductPage {
constructor() {
this.initEssentials();
this.loadNonCritical();
}
initEssentials() {
// 即座に必要な最小限の機能
this.setupNavigation();
this.setupProductImageViewer();
}
async loadNonCritical() {
// ユーザーが実際に使う時に読み込み
const { default: ReviewSystem } = await import('./components/reviews.js');
const { default: RecommendationEngine } = await import('./components/recommendations.js');
// IntersectionObserverで必要になったら初期化
const reviewObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
new ReviewSystem('#reviews-section');
reviewObserver.unobserve(entry.target);
}
});
});
const reviewSection = document.getElementById('reviews-section');
if (reviewSection) {
reviewObserver.observe(reviewSection);
}
}
setupProductImageViewer() {
// Vanilla JSでの軽量実装
const thumbnails = document.querySelectorAll('.product-thumbnail');
const mainImage = document.querySelector('.main-product-image');
thumbnails.forEach(thumb => {
thumb.addEventListener('click', (e) => {
const newSrc = e.target.dataset.fullSize;
mainImage.src = newSrc;
// アクティブ状態の更新
thumbnails.forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
});
});
}
}
// 初期化は DOMContentLoaded で最小限に
document.addEventListener('DOMContentLoaded', () => {
new ProductPage();
});
アプローチ2: Service Workerによるキャッシュ最適化
// sw.js - Service Worker implementation
const CACHE_NAME = 'ec-site-v2';
const CRITICAL_RESOURCES = [
'/',
'/styles/critical.css',
'/js/core.js',
'/images/logo.webp'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(CRITICAL_RESOURCES))
);
});
self.addEventListener('fetch', (event) => {
// 商品画像の長期キャッシュ戦略
if (event.request.url.includes('/images/products/')) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(response => {
if (response) {
return response;
}
return fetch(event.request).then(fetchResponse => {
cache.put(event.request, fetchResponse.clone());
return fetchResponse;
});
});
})
);
}
// API レスポンスの短期キャッシュ
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(response => {
const fetchPromise = fetch(event.request).then(fetchResponse => {
cache.put(event.request, fetchResponse.clone());
return fetchResponse;
});
return response || fetchPromise;
});
})
);
}
});
// メインサイトでの Service Worker 登録
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
FID改善結果
JavaScript実行時間最適化:
├── バンドルサイズ: 280KB → 45KB (84%削減)
├── 初期実行時間: 420ms → 85ms (80%改善)
├── メインスレッドブロック: 1.2秒 → 0.2秒 (83%改善)
└── TTI (Time to Interactive): 4.5秒 → 1.1秒 (76%改善)
実装した監視体制
リアルタイム監視システム
// パフォーマンス監視用のカスタムコード
class PerformanceMonitor {
constructor() {
this.metrics = {};
this.setupObservers();
this.sendMetricsInterval();
}
setupObservers() {
// Core Web Vitals 測定
this.observeLCP();
this.observeFID();
this.observeCLS();
// カスタムメトリクス測定
this.observeCustomMetrics();
}
observeLCP() {
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.lcp = lastEntry.startTime;
this.checkThreshold('lcp', lastEntry.startTime, 2500);
}).observe({entryTypes: ['largest-contentful-paint']});
}
observeFID() {
new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0];
const fid = firstInput.processingStart - firstInput.startTime;
this.metrics.fid = fid;
this.checkThreshold('fid', fid, 100);
}).observe({entryTypes: ['first-input']});
}
observeCLS() {
let clsValue = 0;
let clsEntries = [];
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsEntries.push(entry);
clsValue += entry.value;
}
}
this.metrics.cls = clsValue;
this.checkThreshold('cls', clsValue, 0.1);
}).observe({entryTypes: ['layout-shift']});
}
checkThreshold(metric, value, threshold) {
const isGood = metric === 'cls' ? value <= threshold : value <= threshold;
if (!isGood) {
// Slackアラート送信
this.sendAlert({
metric: metric,
value: value,
threshold: threshold,
url: window.location.href,
userAgent: navigator.userAgent
});
}
}
async sendAlert(data) {
try {
await fetch('/api/performance-alert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
} catch (error) {
console.error('Failed to send alert:', error);
}
}
sendMetricsInterval() {
// 5分ごとにメトリクスを送信
setInterval(() => {
this.sendMetrics();
}, 300000);
}
async sendMetrics() {
const data = {
...this.metrics,
timestamp: Date.now(),
url: window.location.href,
connection: navigator.connection?.effectiveType || 'unknown'
};
try {
await fetch('/api/performance-metrics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
} catch (error) {
console.error('Failed to send metrics:', error);
}
}
}
// 監視開始
new PerformanceMonitor();
ビジネスインパクト
改善結果の詳細
Core Web Vitals の改善
改善前 → 改善後:
モバイル:
├── LCP: 6.8秒 → 1.8秒 (74%改善)
├── FID: 420ms → 65ms (85%改善)
└── CLS: 0.35 → 0.05 (86%改善)
デスクトップ:
├── LCP: 3.2秒 → 1.2秒 (63%改善)
├── FID: 180ms → 45ms (75%改善)
└── CLS: 0.18 → 0.03 (83%改善)
ビジネス指標の改善
KPI改善結果 (3ヶ月後):
└── 離脱率
├── トップページ: 65% → 28% (57%改善)
├── 商品詳細ページ: 58% → 31% (47%改善)
└── カートページ: 72% → 35% (51%改善)
└── コンバージョン関連
├── コンバージョン率: 2.1% → 3.8% (81%向上)
├── 商品詳細閲覧率: 15% → 28% (87%向上)
└── カート追加率: 8% → 18% (125%向上)
└── エンゲージメント
├── ページ滞在時間: 1分20秒 → 2分45秒 (106%向上)
├── ページビュー/セッション: 2.3 → 4.1 (78%向上)
└── 再訪問率: 22% → 41% (86%向上)
売上への直接的な効果
売上インパクト (改善後3ヶ月):
├── 月間売上: 15%向上
├── 新規顧客獲得: 32%向上
├── リピート購入: 28%向上
└── 平均注文価格: 8%向上
投資対効果:
├── 改善コスト: 200万円
├── 売上増加: 月間800万円
└── ROI: 400% (3ヶ月で回収)
失敗から学んだ教訓
失敗事例1: 過度な最適化
// ❌ 失敗した過度な最適化例
// 全ての画像を WebP + AVIF + JPEG で配信しようとした
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<source srcset="image.jxl" type="image/jxl">
<img src="image.jpg" alt="商品画像">
</picture>
// 結果: CDNコストが3倍に増加、管理も複雑化
// 教訓: 実用性とコストのバランスが重要
失敗事例2: 不適切なキャッシュ戦略
// ❌ 問題のあったキャッシュ実装
// 商品情報も長期キャッシュしてしまった
if (event.request.url.includes('/api/product/')) {
// 24時間キャッシュ(価格変更に対応できない)
return cacheFirst(event.request);
}
// ✅ 改善版: データの性質に応じたキャッシュ戦略
if (event.request.url.includes('/api/product/')) {
if (request.url.includes('basic-info')) {
// 基本情報は長期キャッシュOK
return cacheFirst(event.request);
} else {
// 価格・在庫は短期キャッシュ
return networkFirst(event.request);
}
}
成功要因の分析
- 段階的な改善: 一度にすべてを変更せず、影響範囲を限定
- データドリブン: 全ての変更について事前・事後の測定を実施
- ユーザビリティ重視: 技術的最適化がUXを損なわないよう配慮
- 継続的改善: リリース後も監視・改善を継続
次のプロジェクトに向けた改善点
プロジェクト改善チェックリスト:
□ 改善前のベースライン測定
□ 改善目標の明確化(数値・期限)
□ 段階的リリース計画
□ A/Bテスト設計
□ 監視・アラート体制
□ ロールバック計画
□ ステークホルダーへの定期報告
□ 改善効果の長期追跡
まとめ
この改善プロジェクトを通じて学んだ最も重要なことは、技術的な最適化とビジネス価値を両立させることの重要性です。
- パフォーマンス改善は手段であり、目的はユーザー体験の向上
- データに基づいた意思決定が成功の鍵
- 継続的な監視と改善サイクルの構築が不可欠
- チーム全体での取り組みが重要
同様のパフォーマンス改善を検討されている方の参考になれば幸いです。具体的な実装についてご質問があれば、お気軽にお問い合わせください。