1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザの戻る/進むを高速にしたい!:スマホでページ遷移時に download.txt が発生するんだが

1
Last updated at Posted at 2026-04-04

スマホでページ遷移時に download.txt が発生する問題:原因調査と対策のまとめ

個人サイトを作っていて、読み込み速度を高速化させたいと思った。デバック作業をしていたら判明。読み込み順序の操作やキャッシュバスティング(Cache Busting)を試してみたが解消されなかった...

発生環境: iOS Safari・Android Chrome などモバイルブラウザ
症状: ページ遷移時、またはヘッダーの戻るボタン→再遷移時に download.txtdownload.html がダウンロードされてしまう

結論

ページ遷移を早くする仕組みが原因。活用しながら、安全に使うのがベスト

1. 初期仮説と試した対策

仮説① MIME Sniffing(Content-Type の誤認識)

背景
スマホブラウザは X-Content-Type-Options: nosniff が付いていないと、エラーレスポンスの中身を「バイナリ」や「テキストファイル」と誤判定し、ダウンロードとして扱うことがある。

対策
experience/public/web.config(Azure IIS 設定)に以下を追加:

<httpProtocol>
  <customHeaders>
    <add name="X-Content-Type-Options" value="nosniff" />
    <add name="X-Frame-Options"        value="SAMEORIGIN" />
    <add name="Referrer-Policy"        value="strict-origin-when-cross-origin" />
  </customHeaders>
</httpProtocol>

結果: 改善傾向が見られたが、戻るボタン後の再遷移では依然として発生。


仮説② キャッシュの競合(古い JS / HTML の返却)

背景
Cookie が更新されたとき、スマホのブラウザキャッシュが古い JS / HTML を返し、サーバー側のセッション状態とミスマッチが起きている可能性。

対策
web.config にパス別 Cache-Control を設定:

パス 設定 意図
.(ルート・HTML) no-cache, no-store, must-revalidate index.html を常に最新に
_next/static/ public, max-age=31536000, immutable ハッシュ付きファイルは長期キャッシュ
version.json no-cache, no-store, must-revalidate バージョンポーリング用
<location path="." inheritInChildApplications="false">
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <add name="Cache-Control" value="no-cache, no-store, must-revalidate" />
        <add name="Pragma"        value="no-cache" />
        <add name="Expires"       value="0" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</location>

結果: 初回アクセスの問題は大幅改善。しかし戻るボタン後の再遷移には効かなかった。


仮説③ クライアント側でのエラーレスポンス検知(fetchGuard)

背景
サーバーが誤った Content-Type(application/octet-stream など)を返す瞬間があり、ブラウザがダウンロードを開始する前に React 側で検知できれば強制リロードで回避できる。

対策
experience/components/cache-reloader.tsxwindow.fetch をモンキーパッチする installFetchGuard() を実装:

window.fetch = async (...args) => {
  const res = await originalFetch(...args)
  const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url
  if (url && url.includes('/_next/')) return res  // Next.js 内部はスキップ

  if (looksLikeDownload(res)) {
    (window.location as any).reload(true)  // キャッシュスキップして強制リロード
  }
  return res
}

function looksLikeDownload(res: Response): boolean {
  const disposition = res.headers.get('Content-Disposition') ?? ''
  if (disposition.toLowerCase().includes('attachment')) return true

  const contentType = res.headers.get('Content-Type') ?? ''
  return !(
    contentType.includes('text/html') ||
    contentType.includes('application/json') ||
    contentType.includes('text/plain')
  )
}

結果: ランダムに発生するパターンにはある程度効果あり。しかし「戻るボタン→再遷移」の再現性の高いパターンには対応できていなかった。


2. 根本原因の特定:bfcache(Back/Forward Cache)

bfcache とは

bfcache(Back/Forward Cache)は iOS Safari・Android Chrome が搭載するページの前後ナビゲーション高速化機能

  • 戻る・進むボタンが押されたとき、ブラウザはHTTPリクエストを発行せず、JS の実行状態ごと freeze したスナップショットをメモリから復元する
  • Cache-Control: no-store を設定していても、bfcache はスキップしない(仕様上の制約)

なぜ download.txt が発生するか

①  トップ (index.html) へアクセス → Cookie・Session が正常に初期化
②  タッチして、次のページへ遷移
③  戻るボタン → bfcache から復元(HTTPリクエストなし・古い状態のまま)
④  その「古い状態」のまま再度、次のページへ遷移
⑤  サーバー側は更新済みセッション/Cookie を期待 → 状態不一致でエラーレスポンス
⑥  エラーレスポンスの Content-Type が text/html でない → ブラウザがダウンロード開始

4. 一時的な対策

pageshow イベントによる bfcache 検出

pageshow イベントの e.persisted プロパティが true のとき、bfcache から復元されたことを示す。この瞬間に window.location.reload() を呼ぶことで、常に新鮮な HTTP リクエストが発行されるようになる。

index.html(静的ランディングページ)

window.addEventListener('pageshow', function(e) {
  if (e.persisted) {
    console.log('[bfcache] persisted restore detected, reloading...');
    window.location.reload();
  }
});

experience/components/cache-reloader.tsx(Next.js アプリ側)

const handlePageShow = (e: PageTransitionEvent) => {
  if (e.persisted) {
    console.log('[CacheReloader] bfcache restore detected, reloading...')
    window.location.reload()
  }
}
window.addEventListener('pageshow', handlePageShow)

// cleanup
return () => {
  window.removeEventListener('pageshow', handlePageShow)
}

結果: 戻るボタン → 再遷移パターンでも download.txt が発生しなくなった。


5. 実施した対策の全体像

┌─────────────────────────────────────────────────────────────────┐
│ レイヤー         │ 対策                            │ 効果        │
├─────────────────┼─────────────────────────────────┼─────────────┤
│ サーバー         │ X-Content-Type-Options: nosniff  │ MIME誤認防止│
│ (web.config)    │ Cache-Control per-path            │ キャッシュ整理│
├─────────────────┼─────────────────────────────────┼─────────────┤
│ クライアント     │ fetchGuard(fetch モンキーパッチ) │ 実行時検知  │
│ (cache-reloader)│ pageshow bfcache 検出             │ ★根本解決  │
├─────────────────┼─────────────────────────────────┼─────────────┤
│ 静的ページ       │ pageshow bfcache 検出             │ ★根本解決  │
│ (index.html)    │                                   │             │
└─────────────────┴─────────────────────────────────┴─────────────┘

6. 学んだこと・今後の指針

Cache-Control: no-store だけでは bfcache を止められない

標準仕様では no-store が付いていてもブラウザが bfcache を使ってよい。完全に bfcache を無効化したい場合は Cache-Control: no-store に加えて pageshow ハンドラが必要。

補足: Vary: Cookie ヘッダーや Clear-Site-Data ヘッダーを組み合わせることで bfcache への格納自体を抑制できる場合もあるが、ブラウザ実装に依存するため pageshow が最も確実。

② モバイルブラウザ固有の問題は「モバイルで実際に試す」こと

DevTools の Device Emulation では bfcache の挙動が異なり再現しないことが多い。実機テスト(または BrowserStack)が不可欠。

e.persisted による判定は全ブラウザで利用可能

pageshow + e.persisted は HTML Living Standard の公式仕様であり、iOS Safari・Android Chrome・Firefox いずれでも動作する。

④ fetchGuard は「保険」として残す価値あり

bfcache とは別の原因(サーバー側バグ・CDN 設定ミスなど)でダウンロードが誘発される可能性もあるため、fetchGuard は引き続き有効。

ソースコード

新しいバージョンが出ていないか5分ごとに見回りし、見つけたらページを最新に更新する仕組み

'use client'
import { useEffect, useRef } from 'react'
const POLL_INTERVAL_MS = 5 * 60 * 1000 // 5 分

function getVersionUrl(): string {
  const basePath =
    typeof window !== 'undefined' &&
    window.location.pathname.startsWith('/experience')
      ? '/experience'
      : ''
  return `${basePath}/version.json?_cb=${Date.now()}`
}

function looksLikeDownload(res: Response): boolean {
  const disposition = res.headers.get('Content-Disposition') ?? ''
  if (disposition.toLowerCase().includes('attachment')) return true

  const contentType = res.headers.get('Content-Type') ?? ''
  const isSafe =
    contentType.includes('text/html') ||
    contentType.includes('application/json') ||
    contentType.includes('text/plain')
  return !isSafe
}

async function fetchVersion(): Promise<string | null> {
  try {
    const res = await fetch(getVersionUrl(), {
      cache: 'no-store',
      headers: { 'Cache-Control': 'no-cache' },
    })
    if (looksLikeDownload(res)) {
      console.warn(
        '[CacheReloader] 不正なContent-Type / Content-Dispositionを検出。強制リロードします。'
      )
      ;(window.location as any).reload(true)
      return null
    }

    if (!res.ok) return null
    const data = (await res.json()) as { version?: string }
    return data.version ?? null
  } catch {
    return null
  }
}

function installFetchGuard() {
  if (typeof window === 'undefined') return
  if ((window as any).__cacheReloaderGuardInstalled) return
  ;(window as any).__cacheReloaderGuardInstalled = true

  const originalFetch = window.fetch.bind(window)

  window.fetch = async (...args) => {
    const res = await originalFetch(...args)
    const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url
    if (url && url.includes('/_next/')) return res

    if (looksLikeDownload(res)) {
      console.warn(
        `[CacheReloader] fetch "${url}" でダウンロード誘発レスポンスを検出。強制リロードします。`
      )
      ;(window.location as any).reload(true)
    }
    return res
  }
}

export function CacheReloader() {
  const currentVersion = useRef<string | null>(null)
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)

  useEffect(() => {
    installFetchGuard()
    fetchVersion().then((v) => {
      currentVersion.current = v
    })

    // bfcache 対策: 戻るボタンで復元されたとき、バージョンが変わっていれば
    // リロード、変わっていなければ bfcache をそのまま活用して高速表示を維持する
    const handlePageShow = async (e: PageTransitionEvent) => {
      if (!e.persisted) return
      console.log('[CacheReloader] bfcache restore detected, checking version...')
      const latest = await fetchVersion()
      if (!latest) return // ネットワーク失敗 → bfcache のまま

      if (currentVersion.current && latest !== currentVersion.current) {
        // バージョンが変わっていた場合のみリロード
        console.info(
          `[CacheReloader] bfcache restore: バージョン変更検出 (${currentVersion.current.slice(0, 8)}${latest.slice(0, 8)})。リロードします。`
        )
        window.location.reload()
      } else {
        // バージョン変わらず → bfcache 高速復元をそのまま活用
        currentVersion.current = latest
        console.log('[CacheReloader] bfcache restore: バージョン同一。高速復元を活用します。')
      }
    }
    window.addEventListener('pageshow', handlePageShow)

    const check = async () => {
      if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return

      const latest = await fetchVersion()
      if (
        latest &&
        currentVersion.current &&
        latest !== currentVersion.current
      ) {
        console.info(
          `[CacheReloader] 新バージョン検出 (${currentVersion.current?.slice(0, 8)}${latest.slice(0, 8)})。ページをリロードします。`
        )
        window.location.reload()
      }
    }
    timerRef.current = setInterval(check, POLL_INTERVAL_MS)
    return () => {
      if (timerRef.current) clearInterval(timerRef.current)
      window.removeEventListener('pageshow', handlePageShow)
    }
  }, [])
  return null
}


7. 参考リンク


1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?