Article

データ 刷新 ポイントの事例集|成功パターンと学び

高田晃太郎
データ 刷新 ポイントの事例集|成功パターンと学び

書き出し

近年の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直)各画面で都度fetch12505.0025.01.2
B: React Query基本形staleTime=5m, focus再検証9302.21211.20.9
C: B + SWR型SWR/ETagETag+SWR(304多用)9102.1106.30.8
D: C + SW通知/局所無効化SW通知→invalidate9051.785.10.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週間

導入手順・運用と監視

導入は「刷新ポイントの棚卸し→共通方針→段階導入」が失敗しにくい。

実装手順:

  1. データ種類を分類(一覧/詳細/集計/マスタ)し、許容一貫性と寿命(TTL)を定義する。
  2. HTTPレイヤでETag³とCache-Control(stale-while-revalidate含む)²を有効化する。
  3. クライアントにReact QueryまたはSWRを導入し、staleTime・revalidateOnFocusを設定。更新操作に対してinvalidate/mutateの規約を定める。
  4. タブ復帰・ネットワーク回復・ナビゲーション完了を刷新ポイントとしてイベントハンドラを実装する(オンライン復帰時の送信はBackground Syncを検討)⁴。
  5. 大きな参照データはIndexedDBに移し、バージョンAPIで差分のみ取得する⁵。
  6. Service Workerを導入し、APIのStale-While-Revalidateと更新通知を組み合わせる¹²。
  7. 計測ダッシュボードを整備し、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を測定してほしい。改善ループを回せば、無駄な再フェッチは着実に減り、ユーザー価値とコストの双方で成果が出る。

参考文献

  1. web.dev. stale-while-revalidate. https://web.dev/articles/stale-while-revalidate
  2. Mark Nottingham, et al. RFC 5861: HTTP Cache-Control Extensions for Stale Content. https://www.rfc-editor.org/rfc/rfc5861
  3. R. Fielding, et al. RFC 7232: Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests. https://datatracker.ietf.org/doc/html/rfc7232
  4. MDN Web Docs. Background Synchronization API. https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API
  5. MDN Web Docs. Using IndexedDB. https://developer.mozilla.org/docs/Web/API/IndexedDB_API/Using_IndexedDB
  6. 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