スマホでページ遷移時に download.txt が発生する問題:原因調査と対策のまとめ
個人サイトを作っていて、読み込み速度を高速化させたいと思った。デバック作業をしていたら判明。読み込み順序の操作やキャッシュバスティング(Cache Busting)を試してみたが解消されなかった...
発生環境: iOS Safari・Android Chrome などモバイルブラウザ
症状: ページ遷移時、またはヘッダーの戻るボタン→再遷移時にdownload.txtやdownload.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.tsx に window.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. 参考リンク
- Back/Forward Cache (web.dev)
- PageTransitionEvent.persisted (MDN)
- X-Content-Type-Options (MDN)
- Cache-Control (MDN)
- ブラウザの戻る/進むを高速に!ヤフーにおけるBFCache有効化に向けた取り組み
- bfcacheについて