ECサイトが遅く、購入まで進む人も少ないとき、表示速度と購入導線を一緒に見直す価値があります。ただし、まとめて変更した前後の数字だけでは、何が購入に効いたかは分かりません。
この記事は、仮想のECサイトを使った改善設計と計算の教材です。特定企業の成果や「CVRが68%向上した実績」を示すものではありません。以下の訪問者、購入、売上、性能値、A/B結果は合成の例です。実績としての測定記録を提示できない数字を、成功保証として扱いません。
30秒で要点
- 速度はTTFBと現行Core Web VitalsのLCP・INP・CLSで確認する。FIDは現在の指標へ換算できない。
- 購入導線は同じ対象者のファネルで測り、商品情報、送料、入力エラーの仮説を確かめる。
- A/Bテストは分母、割り付け、観測期間、主要指標、比較の数を先に決める。
- 2.5%から4.2%は1.7ポイント増、相対68%増。同時に施策を変えた前後差だけでは、因果効果を示さない。
仮想のサイトと測定条件を定める
教材では、月間の対象者10万人、購入者2,500人、購入者1人につき1注文、平均注文額2万円を仮定します。対象者単位の購入率は2.5%、売上の計算値は5,000万円です。実店舗の報告値ではありません。
実際には、セッション・利用者・注文のどれを分母にするか、キャンセル・返品を含むか、観測期間を先に決めます。広告、端末、リピーター、商品構成、在庫、割引の変化も記録します。
表が収まらない場合は、横にスクロールできます
| 分けて測るもの | 記録する条件 | そこからは分からないこと |
|---|---|---|
| 現場の表示性能 | 端末、URL、期間、データ元、p75 | 速度だけによる売上の増加 |
| 実験の購入率 | 対象者、無作為割り付け、購入確認、観測期限 | 対象外の利用者や季節の効果 |
| エラー・返品・粗利 | 同じ対象者と期間で集計 | CVRだけでは分からない副作用 |
速度の問題を測り直す
現行Core Web VitalsはLCP・INP・CLSです。良好の目安はLCP 2.5秒以下、INP 200ms以下、CLS 0.1以下で、端末別の75パーセンタイルを使います。INPは2024年3月12日にFIDを置き換えました。web.devの指標定義、INPへの移行
TTFBは最初の応答が届くまでの補助指標で、Core Web Vitalsそのものではありません。web.devは良好の目安を0.8秒以下としています。TTFBの定義
次のコードは測定APIではなく、値の単位を揃えた仮想の集計入力です。INP値は別に置いた仮想値で、昔のFIDから変換した値ではありません。
const baselinePerformance = {
synthetic: true,
aggregation: 'assumed_p75',
segment: 'mobile-product-page',
ttfbMs: 1200,
lcpMs: 4500,
inpMs: 320,
cls: 0.25,
fcpMs: 2800,
};
実際にはPageSpeed Insights等の現場データと、条件を揃えたラボの診断を分けて保存します。CrUX APIの集約窓は28日です。新しいページでは、期間表示が28日でも全期間にデータがあるとは限りません。更新直後の値は更新前の体験を含みえます。CrUX APIの集約条件
LCPをTTFB、画像などの読み込み開始までの遅れ、読み込み時間、描画待ちに分けると、調べる場所を絞れます。サーバーの応答だけを速くしても、画像の発見や描画が遅ければLCPは残ります。LCPの分解
表が収まらない場合は、横にスクロールできます
| 問題の仮説 | 調べる証拠 | 改善候補 |
|---|---|---|
| サーバー・DBの処理が長い | サーバー計測、クエリ計画、キャッシュの有無 | 必要な列・件数、インデックス、公開情報のキャッシュ |
| 主画像を見つけるのが遅い | ネットワークの開始時刻、HTML | HTMLから発見できる画像、適切な優先度 |
| 操作中に応答が止まる | 遅い操作の記録、長い処理 | 処理・イベントの分割、不要な処理の削減 |
| 画面の位置が動く | シフトの発生箇所 | 画像の寸法、広告領域、フォントの計画 |
ファネルの数と仮説を分ける
次の合成例は、同じ期間の対象者を重複なく数え、各段階を順に通った人数として扱います。イベント発火の回数や、段階ごとに異なる母集団を混ぜる集計ではありません。
function summarizeFunnel(counts) {
const keys = ['eligibleUsers', 'productUsers', 'cartUsers', 'checkoutUsers', 'buyers'];
const values = keys.map(key => counts[key]);
if (values.some(n => !Number.isSafeInteger(n) || n < 0) ||
values.some((n, i) => i > 0 && n > values[i - 1])) {
throw new Error('Use nonnegative, ordered counts from the same cohort');
}
const pct = (part, whole) => whole ? 100 * part / whole : null;
return {
purchasePercent: pct(counts.buyers, counts.eligibleUsers),
productToCartDropPercent: counts.productUsers ? 100 - pct(counts.cartUsers, counts.productUsers) : null,
cartToCheckoutDropPercent: counts.cartUsers ? 100 - pct(counts.checkoutUsers, counts.cartUsers) : null,
checkoutToPurchaseDropPercent: counts.checkoutUsers ? 100 - pct(counts.buyers, counts.checkoutUsers) : null,
};
}
const syntheticFunnel = {
eligibleUsers: 100000, productUsers: 60000,
cartUsers: 12000, checkoutUsers: 6000, buyers: 2500,
};
const funnelSummary = summarizeFunnel(syntheticFunnel);
この計算で購入率は2.5%、商品閲覧からカートまでの離脱は80%、カートから購入手続きは50%、購入手続きから購入は約58.33%です。離脱の理由は、この数だけでは分かりません。
送料、商品情報、在庫、入力エラー、決済の失敗を仮説として挙げ、観察や問い合わせ、エラー記録と照合します。ヒートマップで長く見ていた場所が、理解不足なのか関心なのかも単独では判断できません。入力内容や個人情報を録画・分析へ送らない設定と利用条件を確認します。
性能改善の実装を小さく切る
DBは値の連結と架空のキャッシュ設定を避ける
node-postgresはクエリ文字列と値を分けて渡します。次の関数は公開商品だけを最大20件取得する例です。dbは接続済みのクライアントまたはPoolを受け取ります。公式のparameterized query
async function listPublicProducts(db, categoryId) {
if (!Number.isSafeInteger(categoryId) || categoryId <= 0) {
throw new Error('Invalid category ID');
}
const result = await db.query(
`SELECT id, name, price, image_url
FROM products
WHERE category_id = $1 AND is_public = TRUE
ORDER BY created_at DESC, id DESC
LIMIT $2`,
[categoryId, 20]
);
return result.rows;
}
DBクライアントに存在しないuseCacheやcacheTTLを渡しても、キャッシュの設計にはなりません。必要なインデックスは、実際の件数とクエリ計画で確認します。比較は同じデータ量・条件で行い、キャッシュが温まった状態と未使用の状態を区別します。
公開商品ページをキャッシュしても、価格・在庫は鮮度を別に保ちます。ログイン後のカートや個人向け価格を共通キャッシュへ混ぜないでください。HTTP cacheの共有・認証条件
HTTP/2を使うこととServer Pushを勧めることも別です。ChromeはServer Pushの無効化を案内しています。不要な先読みを増やさず、実際の資源読み込みを測ります。ChromeのServer Push廃止案内
LCPの主画像と画面外の画像を分ける
以下の画像名は教材の例です。実際には、各幅に対応する画像を用意します。主画像をHTMLで発見できるようにし、寸法を指定します。LCP候補の主画像へloading="lazy"を付けません。
<picture>
<source type="image/webp"
srcset="product-480.webp 480w, product-960.webp 960w"
sizes="(max-width: 600px) 100vw, 600px">
<img src="product-960.jpg"
alt="教材の商品:青い布製バッグの正面"
width="960" height="640"
fetchpriority="high" loading="eager">
</picture>
画面外の補足画像は別に遅延読み込みを検討します。Critical CSSの抽出やフォントの読み込みは、重複配信・表示崩れ・文字の置き換わりも測ります。フォントのswapだけでCLSが解決するとは限りません。広告やおすすめ領域も、必要な場所を先に確保してから内容を入れます。
CROでは判断に必要な情報を揃える
商品情報と送料を先に見せる
商品仕様、価格、配送条件、返品条件、確認できた在庫を示します。購入者数やレビューは根拠がある場合だけ表示し、架空の人数や切迫した文言で購入を促しません。
次は教材用の静的HTMLです。在庫や金額は表示例であり実在商品の値ではありません。実装ではサーバー側の正式な情報で差し替え、カート追加時にも価格と在庫を再確認します。
<section aria-labelledby="product-name">
<h2 id="product-name">教材用の布製バッグ</h2>
<p>表示例の価格:2,000円(税込)</p>
<p>表示例の送料:500円。実際の配送条件は注文前に確認します。</p>
<p>在庫表示例:3個。注文確定時に再確認します。</p>
<form method="post" action="/example-cart-add">
<input type="hidden" name="product_id" value="demo-bag">
<label for="quantity">数量</label>
<input id="quantity" name="quantity" type="number" min="1" max="3" value="1" required>
<button type="submit">カートに追加する</button>
</form>
</section>
/example-cart-addは読者が実装する教材用の送信先で、このHTMLに注文処理は含まれません。認証、CSRF、価格・在庫の再検証、二重送信や失敗時の扱いを別に実装します。色や文言を変更しても購入が増えるとは限らず、理解と操作のしやすさを確かめます。
カートと入力フォームを見直す
カートでは商品、数量、送料、合計、配送と返品の条件を購入前に確認できるようにします。証明のない認証バッジや「SSLだから安全」という説明を、支払い全体の保証にしません。
入力項目を必要なものに絞り、ラベル、入力形式、エラーの修正方法を揃えます。次はメール欄の静的例です。ラベルと入力欄を関連づけ、補足を読み取れるようにします。WAIのラベル
<form method="post" action="/example-checkout">
<label for="checkout-email">連絡先メールアドレス(必須)</label>
<input id="checkout-email" name="email" type="email"
autocomplete="email" required aria-describedby="email-help">
<p id="email-help">注文に関する連絡先を入力してください。</p>
<button type="submit">入力内容を確認する</button>
</form>
ブラウザ側の入力検証は、サーバー側の検証を代替しません。送信後のエラーには場所と修正方法を示し、入力した内容を不必要に失わせないようにします。上の例は送信先・決済処理を含まない教材で、実機のキーボードや読み上げの確認も別に必要です。WAIの入力検証
決済方法やステップ表示は対象者の利用条件から選びます。ステップを減らすために必要な確認を省いたり、推測した残り時間を確定表示したりしないでください。
A/Bテストは結果を置く前に設計する
購入を主要指標にする場合、同じ人がA/Bをまたがない割り付け、対象者の重複除外、購入確認、観測期限を定めます。端末、広告、在庫や価格変更の条件も揃えます。途中の良い数字だけで止めず、事前に決めた方法で判断します。
例として、CTA、在庫表示、レビュー表示の3比較をひとつの検定族として事前に選んだと仮定します。以下は各群10,000人の合成結果です。レビューや在庫の情報が事実であることは、実験の前提です。
const syntheticExperiments = [
{ label: 'CTA', a: { users: 10000, buyers: 250 }, b: { users: 10000, buyers: 320 } },
{ label: 'stock', a: { users: 10000, buyers: 250 }, b: { users: 10000, buyers: 300 } },
{ label: 'review', a: { users: 10000, buyers: 250 }, b: { users: 10000, buyers: 290 } },
];
各10,000人という数を、どのテストでも十分な標本数とは考えません。想定する現状率、検出したい差、誤判定の許容、検出力から事前に検討します。
合成結果の計算を再現する
次はPython標準ライブラリだけで、独立した二群の購入率を正規近似で比較する例です。連続性補正なしの両側z検定と、Holmによる3比較の調整を計算します。小標本や希少事象ではこの方法をそのまま使わず、適切な検定・区間を選びます。比率z検定の条件、Holmの調整
import json
import math
from statistics import NormalDist
def compare_proportions(x_a, n_a, x_b, n_b):
values = (x_a, n_a, x_b, n_b)
if any(type(v) is not int for v in values) or n_a <= 0 or n_b <= 0:
raise ValueError("integer counts and positive denominators are required")
if not 0 <= x_a <= n_a or not 0 <= x_b <= n_b:
raise ValueError("invalid buyer count")
p_a, p_b = x_a / n_a, x_b / n_b
pooled = (x_a + x_b) / (n_a + n_b)
if min(n_a * pooled, n_a * (1 - pooled), n_b * pooled, n_b * (1 - pooled)) < 5:
raise ValueError("normal approximation is unsuitable for this example")
se_null = math.sqrt(pooled * (1 - pooled) * (1 / n_a + 1 / n_b))
z = (p_b - p_a) / se_null
p = math.erfc(abs(z) / math.sqrt(2))
se_diff = math.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b)
margin = NormalDist().inv_cdf(0.975) * se_diff
return {"difference_pp": 100 * (p_b - p_a), "p_unadjusted": p,
"ci95_unadjusted_pp": [100 * (p_b - p_a - margin), 100 * (p_b - p_a + margin)]}
def holm_adjust(pvalues):
if any(type(p) not in {int, float} or not math.isfinite(p) or not 0 <= p <= 1 for p in pvalues):
raise ValueError("invalid p-value")
adjusted = [0.0] * len(pvalues)
running = 0.0
for rank, i in enumerate(sorted(range(len(pvalues)), key=lambda i: pvalues[i])):
running = max(running, min(1.0, pvalues[i] * (len(pvalues) - rank)))
adjusted[i] = running
return adjusted
rows = [compare_proportions(250, 10000, b, 10000) for b in (320, 300, 290)]
adjusted = holm_adjust([row["p_unadjusted"] for row in rows])
for label, row, p_adj in zip(("CTA", "stock", "review"), rows, adjusted):
print(json.dumps({"label": label, **row, "p_holm": p_adj}, ensure_ascii=False))
表が収まらない場合は、横にスクロールできます
| 合成の比較 | A→Bの購入率 | ポイント差 | 未調整p | Holm調整後p |
|---|---|---|---|---|
| CTA | 2.5%→3.2% | +0.7 | 約0.00293 | 約0.00880 |
| 在庫表示 | 2.5%→3.0% | +0.5 | 約0.03062 | 約0.06125 |
| レビュー表示 | 2.5%→2.9% | +0.4 | 約0.08098 | 約0.08098 |
この仮定と5%水準では、調整後に差を検出するのはCTAだけです。「3つとも有意」という結果にはなりません。コードの95%区間は各比較の未調整区間で、3比較を同時に覆う区間ではありません。p値だけで採用せず、差の大きさ、実装費用、返品・エラーなどの副作用を読みます。
CTAの相対28%、在庫の20%、レビューの16%を足して64%にしたり、それらを掛けて全体の成果としたりしません。相互作用や対象者の重複があり、同時の施策なら別の実験設計が必要です。
性能の変化と売上の計算を区別する
以下も仮想の性能入力です。比較の単位を保持し、相対減少率を計算します。実際の測定では、同じ端末・ページ・期間・集計条件を確認します。
const candidatePerformance = {
synthetic: true, aggregation: 'assumed_p75', segment: 'mobile-product-page',
ttfbMs: 500, lcpMs: 1800, inpMs: 180, cls: 0.08, fcpMs: 1500,
};
function relativeDecreasePercent(before, after) {
if (!Number.isFinite(before) || !Number.isFinite(after) || before <= 0 || after < 0) {
throw new Error('Use finite values in the same unit and a positive baseline');
}
return 100 * (before - after) / before;
}
const syntheticPerformanceChanges = Object.fromEntries(
['ttfbMs', 'lcpMs', 'inpMs', 'cls', 'fcpMs'].map(key => [
key, relativeDecreasePercent(baselinePerformance[key], candidatePerformance[key])
])
);
合成例ではTTFB約58.33%、LCP60%、INP43.75%、CLS68%、FCP約46.43%の減少になります。これは算術であり、実測の報告や購入への因果効果ではありません。
CVRについても同じように、ポイント差と相対差を分けます。
function comparePurchaseScenario(users, beforeBuyers, afterBuyers, averageOrderYen) {
if (![users, beforeBuyers, afterBuyers, averageOrderYen].every(Number.isSafeInteger) ||
users <= 0 || beforeBuyers <= 0 || afterBuyers < 0 ||
beforeBuyers > users || afterBuyers > users || averageOrderYen < 0) {
throw new Error('Invalid synthetic counts');
}
const beforeRate = beforeBuyers / users;
const afterRate = afterBuyers / users;
return {
differencePp: 100 * (afterRate - beforeRate),
relativeIncreasePercent: 100 * (afterRate - beforeRate) / beforeRate,
extraBuyers: afterBuyers - beforeBuyers,
beforeRevenueYen: beforeBuyers * averageOrderYen,
afterRevenueYen: afterBuyers * averageOrderYen,
};
}
const syntheticPurchaseScenario = comparePurchaseScenario(100000, 2500, 4200, 20000);
対象者10万人と平均注文額2万円を固定した計算なので、購入者は1,700人増、売上は5,000万円から8,400万円になります。注文額や返品、粗利、獲得費用が変われば、この関係は変わります。仮想の2.5%→4.2%を「68%改善の実績」と呼ばないことが大切です。
段階的に変更して、続ける条件を決める
表が収まらない場合は、横にスクロールできます
| 段階 | 実施すること | 続ける前に確認すること |
|---|---|---|
| 現状と計測 | 分母・期間・性能・購入確認を揃える | 重複や欠測、広告・在庫条件の変化 |
| 性能の修正 | 一つずつ変更し、診断と現場値を保存 | 描画崩れ、操作や購入のエラー |
| 購入導線の実験 | 事前設計したA/Bを行う | 割り付け、標本数、観測期限、比較の調整 |
| 判断と監視 | 差と副作用、費用・粗利を確認 | 返品・苦情・運用負荷、撤回する条件 |
表示速度とCROを同じ計画で扱っても、相乗効果は自動で生まれません。変更記録を残し、何を比較して判断したかを読めるようにします。季節や広告の変化を除けない前後比較は、その限界を報告します。
自社の最初の測定を決める
まず、同じ対象者の購入ファネルと、端末別の表示性能を保存してください。TTFBガイド、Webパフォーマンス完全ガイド、CRO完全ガイド、A/Bテストの標本設計と落とし穴、成果の出るWebサイト設計も判断の助けになります。
