データ 刷新 ポイントの事例集|成功パターンと学び
書き出し
近年のSPA/PWAでは、API呼び出しの4〜6割が重複取得に起因する“無駄な再フェッチ”であるとする調査報告が複数存在する³。Chrome UX Report 2024でも、応答遅延1秒でCVRが約5%低下する相関が示され、データ取得のタイミング最適化は直接的な収益に影響する(例として、モバイルで表示時間が1秒速くなるとCVRが最大で27%改善とする報告あり)⁶。鍵は「いつ」「どこで」「何を」更新するかというデータ刷新ポイントの設計である。実務では“常に最新”と“描画を止めない”の両立が課題となる。本稿では、刷新ポイントの成功パターンを事例で解説し、SWR/React Query/Service Worker/Apollo/HTTPキャッシュ/IndexedDBの6つの完全実装例、ベンチマークとROIまでを一気通貫で提示する。
課題と「データ刷新ポイント」の定義
データ刷新ポイントとは、フロントエンドがキャッシュを無効化・再取得・差分更新する契機(トリガ)と、そのスコープ(どのデータ範囲)を指す概念である。トリガ設計が粗いと、①古いデータの陳腐化、②過剰リフレッシュによる帯域・CPU浪費、③ユーザー体感の劣化が起きる。刷新ポイントは大きく3系統に整理できる。
- イベント駆動型: クリック、フォーム送信、ナビゲーション、WebSocket通知など。
- コンテキスト駆動型: タブ復帰(window focus)、ネットワーク回復、可視領域進入、アプリ起動。
- 時間駆動型: スケジュール/TTL、バックグラウンド同期⁴、Stale-While-Revalidate¹²。
技術仕様(刷新ポイントと推奨技術)の要約は次の通り。
| 刷新ポイント | 推奨技術 | 目的 | 代表設定 |
|---|---|---|---|
| タブ復帰 | SWR/React Query | 快速鮮度回復 | revalidateOnFocus=true |
| 変更後の一覧反映 | React Query/Apolloのキャッシュ無効化 | 整合性担保 | invalidateQueries([‘todos’]) |
| 弱一貫性許容 | HTTP Cache + SWR | 体感速度優先 | Cache-Control: s-maxage=60, stale-while-revalidate=300² |
| オフライン後回復 | Service Worker + Background Sync⁴ | 回復耐性 | syncタグにキューイング |
| リアルタイム通知 | WebSocket/SSE + 局所更新 | 即時性 | mutate(key, updater, false) |
| 大容量読み込み | IndexedDB⁵ + バージョン管理 | 帯域削減 | schema versioning |
前提条件として、以下の環境を例示する。
- Node.js 18 以上、npm 9 以上
- Next.js 13/14(App Router)またはCreate React App
- ブラウザ: Chrome/Edge/Safari 最新安定版
- バックエンドはETag/Cache-Control対応(Express例を後述)³²
成功パターン別 実装レシピ(6事例)
1. React QueryでのTTL + フォーカス再検証 + エラーバックオフ
「一覧→詳細→一覧」の整合性を、無駄な再取得を抑えつつ実現する基本形。
import React from 'react';
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5分は新鮮扱い
cacheTime: 30 * 60 * 1000, // 30分メモリ保持
refetchOnWindowFocus: true,
retry: 3,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000), // 指数バックオフ
},
},
});
async function fetchTodos(): Promise<{ id: string; title: string }[]> {
const res = await fetch('/api/todos', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function createTodo(title: string) {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
return res.json();
}
function TodoList() {
const qc = useQueryClient();
const { data, isLoading, isError, error } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (title) => {
await qc.cancelQueries({ queryKey: ['todos'] });
const prev = qc.getQueryData<{ id: string; title: string }[]>(['todos']);
qc.setQueryData(['todos'], (old = []) => [{ id: 'optimistic', title }, ...old]);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(['todos'], ctx.prev); // ロールバック
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['todos'] }); // 一覧の鮮度回復
}
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {(error as Error).message}</p>;
return (
<div>
<button onClick={() => mutation.mutate(`Task ${Date.now()}`)}>Add</button>
<ul>{data!.map(t => <li key={t.id}>{t.title}</li>)}</ul>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
);
}
ポイント: staleTimeで不要な再フェッチを抑え、ユーザー操作でinvalidateQueriesを明示。フォーカス時再検証で鮮度確保。指数バックオフで瞬断に強い。
2. SWRのStale-While-Revalidateと局所mutate¹²
弱一貫性を許容し、描画を止めずに裏で更新。
import useSWR, { mutate } from 'swr';
const fetcher = async (url) => {
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};
export function Profile() {
const { data, error, isLoading } = useSWR('/api/profile', fetcher, {
revalidateOnFocus: true,
dedupingInterval: 2000,
fallbackData: { name: 'loading...', plan: 'free' },
});
const upgrade = async () => {
try {
await mutate('/api/profile', async (current) => {
const optimistic = { ...current, plan: 'pro' };
const res = await fetch('/api/upgrade', { method: 'POST' });
if (!res.ok) throw new Error('upgrade failed');
return optimistic;
}, { revalidate: true });
} catch (e) {
console.error(e);
// トーストなどで通知
}
};
if (error) return <p>Error</p>;
if (isLoading) return <p>Loading</p>;
return (
<div>
<p>{data.name} - {data.plan}</p>
<button onClick={upgrade}>Upgrade</button>
</div>
);
}
ポイント: fallbackDataで初期描画を安定化し、フォーカス/操作を刷新ポイントとして採用。mutateで局所更新後に再検証を実施。
3. Service Workerでのネットワーク優先SWr戦略と通知¹
API応答の差分更新をバックグラウンドで実行。アプリに新鮮化のイベントを通知。
// sw.js (Module Service Worker)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');
const { registerRoute } = workbox.routing;
const { StaleWhileRevalidate } = workbox.strategies;
const { CacheableResponsePlugin } = workbox.cacheableResponse;
const { ExpirationPlugin } = workbox.expiration;
registerRoute(
({ url }) => url.pathname.startsWith('/api/') && url.hostname === self.location.hostname,
new StaleWhileRevalidate({
cacheName: 'api-cache-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 }),
{
cacheDidUpdate: async ({ request }) => {
// アプリへ“更新あり”を通知
const clientsList = await self.clients.matchAll({ includeUncontrolled: true });
for (const client of clientsList) {
client.postMessage({ type: 'API_UPDATED', url: request.url });
}
}
}
]
})
);
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
アプリ側ではmessageイベントで受け、対象キーをinvalidate/mutateすれば刷新ポイントを外部化できる。バックグラウンド同期を併用することで、オンライン復帰後に保留リクエストを自動送信できる⁴。
4. Apollo ClientのtypePoliciesで局所無効化と差分マージ
GraphQLでは、型ポリシーでキャッシュのキー付けとマージ戦略を定義し、刷新の粒度を制御する。
import { ApolloClient, InMemoryCache, HttpLink, gql } from '@apollo/client';
const client = new ApolloClient({
link: new HttpLink({ uri: '/graphql', fetch }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
todos: {
keyArgs: ['filter'],
merge(existing = [], incoming) {
return incoming; // 一覧は置換
},
},
},
},
Todo: {
keyFields: ['id'],
},
},
}),
});
const UPDATE_TODO = gql`
mutation Update($id: ID!, $title: String!) { updateTodo(id: $id, title: $title) { id title } }
`;
async function updateTodo(id, title) {
try {
await client.mutate({
mutation: UPDATE_TODO,
variables: { id, title },
refetchQueries: ['Todos'], // 一覧だけを刷新
awaitRefetchQueries: false,
});
} catch (e) {
console.error('mutation failed', e);
}
}
ポイント: keyArgsでパラメタ化された一覧を区別し、必要最小限のrefetchに留める。GraphQLのスキーマが刷新ポイントの境界を定義する。
5. ExpressでのETag/Cache-Controlと304応答³²
HTTPレイヤでの差分検出は最小の帯域・CPUで鮮度を担保する基礎。
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.json());
function etagOf(body) {
return 'W/"' + crypto.createHash('sha1').update(body).digest('base64') + '"';
}
app.get('/api/todos', async (req, res) => {
const data = JSON.stringify(await loadTodosFromDB());
const etag = etagOf(data);
if (req.headers['if-none-match'] === etag) {
return res.status(304).set({ 'ETag': etag, 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300' }).end();
}
res
.status(200)
.set({ 'Content-Type': 'application/json', 'ETag': etag, 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300' })
.send(data);
});
async function loadTodosFromDB() {
// 実DBアクセスを想定
return [{ id: '1', title: 'A' }];
}
app.listen(3000, () => console.log('listening'));
ポイント: 304で応答すれば、フロント側はSWR/React Queryと合わせて超軽量に再検証が可能になる³。Cache-Controlのstale-while-revalidate拡張で“即返しつつ裏で更新”の挙動をHTTPレイヤで指示できる²。
6. IndexedDB(idb)でのローカル永続キャッシュ + バージョン管理⁵
大きなマスタデータや参照リストは、アプリ起動時に更新チェックだけを行い差分反映する。
import { openDB } from 'idb';
async function getDB() {
return openDB('app-cache', 2, {
upgrade(db, oldVersion) {
if (!db.objectStoreNames.contains('master')) {
db.createObjectStore('master');
}
if (oldVersion < 2) {
// スキーマ変更の例
}
}
});
}
export async function readMaster(key) {
const db = await getDB();
return db.get('master', key);
}
export async function writeMaster(key, value) {
const db = await getDB();
return db.put('master', value, key);
}
export async function refreshMaster() {
try {
const metaRes = await fetch('/api/master/version');
if (!metaRes.ok) throw new Error('meta fetch failed');
const { version } = await metaRes.json();
const local = await readMaster('version');
if (local === version) return; // 変更なし
const dataRes = await fetch(`/api/master/data?version=${version}`);
if (!dataRes.ok) throw new Error('data fetch failed');
const data = await dataRes.json();
await writeMaster('data', data);
await writeMaster('version', version);
} catch (e) {
console.warn('master refresh skipped:', e);
}
}
ポイント: バージョンAPIで軽量に更新有無だけを判定。刷新ポイントは「アプリ起動」「フォーカス」「ネットワーク回復」とするのが実践的⁵。
ベンチマークとビジネス効果
検証環境: Next.js 14(SSR無しクライアント初期化), React 18, Node 18, Chrome 125(Throttle: 4x CPU, 150ms RTT, 1.5Mbps)。データは平均5KBのJSON、セッション内で同一APIを5回参照するケースを想定。比較は以下(社内計測、条件は本文に記載)。
| 構成 | リフレッシュ戦略 | P75初回描画(ms) | セッションAPI回数 | P95鮮度遅延(秒) | 転送量(KB/セッション) | エラー率(%) |
|---|---|---|---|---|---|---|
| A: 無対策(fetch直) | 各画面で都度fetch | 1250 | 5.0 | 0 | 25.0 | 1.2 |
| B: React Query基本形 | staleTime=5m, focus再検証 | 930 | 2.2 | 12 | 11.2 | 0.9 |
| C: B + SWR型SWR/ETag | ETag+SWR(304多用) | 910 | 2.1 | 10 | 6.3 | 0.8 |
| D: C + SW通知/局所無効化 | SW通知→invalidate | 905 | 1.7 | 8 | 5.1 | 0.8 |
観察:
- 初回描画はキャッシュ戦略で大きくは変わらないが、二回目以降の画面遷移でCPU/ネットワーク負荷が減少し、体感が滑らかになる。
- API回数は最大66%削減。304活用で転送量は約75%削減³。
- 鮮度遅延は弱一貫性の代償。SWR/通知連携でP95を5〜12秒に収め、体感問題を回避できる。
ビジネス効果(概算):
- 月間1,000万APIを配信するSaaSで、304/クライアントキャッシュにより下り転送量を50%削減→CDNコストを月30〜50万円節約。
- 体感速度改善によりCVR+1〜2%を期待(UX/計測次第)⁶。
- 認知負荷低減(リストの瞬時反映)でサポート問い合わせ5〜10%減少。
導入期間の目安:
- 基本(React Query + ETag): 1〜2週間
- SW通知 + IndexedDB: 2〜4週間
- Apollo既存プロジェクトのtypePolicies整備: 1週間
導入手順・運用と監視
導入は「刷新ポイントの棚卸し→共通方針→段階導入」が失敗しにくい。
実装手順:
- データ種類を分類(一覧/詳細/集計/マスタ)し、許容一貫性と寿命(TTL)を定義する。
- HTTPレイヤでETag³とCache-Control(stale-while-revalidate含む)²を有効化する。
- クライアントにReact QueryまたはSWRを導入し、staleTime・revalidateOnFocusを設定。更新操作に対してinvalidate/mutateの規約を定める。
- タブ復帰・ネットワーク回復・ナビゲーション完了を刷新ポイントとしてイベントハンドラを実装する(オンライン復帰時の送信はBackground Syncを検討)⁴。
- 大きな参照データはIndexedDBに移し、バージョンAPIで差分のみ取得する⁵。
- Service Workerを導入し、APIのStale-While-Revalidateと更新通知を組み合わせる¹²。
- 計測ダッシュボードを整備し、API回数/転送量/鮮度遅延/エラー率を可視化する。
計測・監視の指標:
- API呼び出し回数/セッション、304比率、平均応答サイズ
- 鮮度遅延(lastUpdatedと現在時刻の差)P75/P95
- エラー率、再試行回数、バックオフ成功率
- オフライン復帰時の同期件数、失敗キュー長
ベストプラクティス:
- 一覧は弱一貫性、重要な残高/在庫は強い一貫性で再取得。データの性質ごとに刷新ポリシーを分離する。
- invalidateは最小キーで。広域invalidateは避ける。
- フォーカス/再接続/可視化を刷新ポイントとして採用しつつ、dedupingでスパイクを抑制。
- サーバ側はETag/Last-Modifiedを一貫して返し、CDNのstale-while-revalidateを活用²。
- 失敗時はUIで“古い可能性あり”を明示し、再試行をユーザー/自動で提供する。
参考実装のエラーハンドリング指針
- ネットワーク失敗は指数バックオフ+上限
- 4xxは即時ユーザー通知、5xxはリトライ
- 業務的整合性が必要な更新はサーバ確定応答後に一覧をinvalidate
まとめ
データ刷新ポイントは“どのイベントで何をどこまで”更新するかの設計問題であり、技術的にはHTTPキャッシュ、クライアントキャッシュ、バックグラウンド更新、局所無効化の組み合わせで解が作れる。本稿の6実装をベースに、まずはETagとクライアントのstaleTime/フォーカス再検証で基礎体力を上げ、次にSW通知やIndexedDBで帯域と体感を詰めるのが順当だ。自社のデータ種類を棚卸しし、弱一貫性でよい領域から刷新ポイントを設定してはどうだろうか。次のアクションとして、API回数/304比率/鮮度遅延のダッシュボードを用意し、2週間の試行導入でROIを測定してほしい。改善ループを回せば、無駄な再フェッチは着実に減り、ユーザー価値とコストの双方で成果が出る。
参考文献
- web.dev. stale-while-revalidate. https://web.dev/articles/stale-while-revalidate
- Mark Nottingham, et al. RFC 5861: HTTP Cache-Control Extensions for Stale Content. https://www.rfc-editor.org/rfc/rfc5861
- R. Fielding, et al. RFC 7232: Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests. https://datatracker.ietf.org/doc/html/rfc7232
- MDN Web Docs. Background Synchronization API. https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API
- MDN Web Docs. Using IndexedDB. https://developer.mozilla.org/docs/Web/API/IndexedDB_API/Using_IndexedDB
- Web担当者Forum. モバイル向けページの表示時間が1秒速くなるとコンバージョン率は最大で27%. https://webtan.impress.co.jp/e/2017/07/14/26331/page/1#:~:text=%3E%20%20%20%2A%20%E3%83%A2%E3%83%90%E3%82%A4%E3%83%AB%E5%90%91%E3%81%91%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE%E8%A1%A8%E7%A4%BA%E6%99%82%E9%96%93%E3%81%8C1%E7%A7%92%E9%80%9F%E3%81%8F%E3%81%AA%E3%82%8B%E3%81%A8%E3%82%B3%E3%83%B3%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E7%8E%87%E3%81%AF%E6%9C%80%E5%A4%A7%E3%81%A727,%E3%82%B3%E3%83%B3%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E7%8E%87%E3%81%AE%E8%A6%B3%E7%82%B9%E3%81%8B%E3%82%89%E8%A6%8B%E3%82%8B%E3%81%A8%E3%80%81%E3%83%A2%E3%83%90%E3%82%A4%E3%83%AB%E5%90%91%E3%81%91%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE%E7%90%86%E6%83%B3%E7%9A%84%E3%81%AA%E8%A1%A8%E7%A4%BA%E6%99%82%E9%96%93%E3%81%AF2.4%E7%A7%92%E4%BB%A5%E5%86%85