パフォーマンス

ユーザー離脱率50%削減した実際のWebパフォーマンス最適化事例

カービー
ユーザー離脱率50%削減した実際のWebパフォーマンス最適化事例
#パフォーマンス最適化#Core Web Vitals#実体験#ECサイト#UX改善

ECサイトのCore Web Vitals改善で離脱率を50%削減した実際のプロジェクト事例。具体的な改善手法と効果測定の詳細を公開。

目次

  1. プロジェクト背景
  2. 問題の特定と分析
  3. LCP改善: 画像最適化の取り組み
  4. CLS改善: レイアウトシフト解決
  5. FID改善: JavaScript最適化
  6. 実装した監視体制
  7. ビジネスインパクト
  8. 失敗から学んだ教訓

プロジェクト背景

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);
    }
}

成功要因の分析

  1. 段階的な改善: 一度にすべてを変更せず、影響範囲を限定
  2. データドリブン: 全ての変更について事前・事後の測定を実施
  3. ユーザビリティ重視: 技術的最適化がUXを損なわないよう配慮
  4. 継続的改善: リリース後も監視・改善を継続

次のプロジェクトに向けた改善点

プロジェクト改善チェックリスト:
□ 改善前のベースライン測定
□ 改善目標の明確化(数値・期限)
□ 段階的リリース計画
□ A/Bテスト設計
□ 監視・アラート体制
□ ロールバック計画
□ ステークホルダーへの定期報告
□ 改善効果の長期追跡

まとめ

この改善プロジェクトを通じて学んだ最も重要なことは、技術的な最適化とビジネス価値を両立させることの重要性です。

  • パフォーマンス改善は手段であり、目的はユーザー体験の向上
  • データに基づいた意思決定が成功の鍵
  • 継続的な監視と改善サイクルの構築が不可欠
  • チーム全体での取り組みが重要

同様のパフォーマンス改善を検討されている方の参考になれば幸いです。具体的な実装についてご質問があれば、お気軽にお問い合わせください。