Chrome の Back button を押した際に「意図しない Cache」が利用されて、期待と違うページが表示される問題について調査した
以前、同僚と話していた際に「同じ URL で Accept
request header に応じて response の Content-Type
を変えていると、Chrome で Back button を押した際の結果が意図しないものになる」ということが話題になりました。
これは、自分も過去に経験した事があり、不思議に思っていた部分です。Safari や Firefox では発生せず、Chrome だけで発生するというのもポイントです。この挙動について、今回は調査してみました。
TL; DR
今回の調査内容を簡潔にまとめると、以下のようになります。
- 同じ URL で
Accept
request header に応じて response のContent-Type
を変えると、Chrome では Back button を押した際に意図しないコンテンツが表示される事がある。Safari や Firefox ではこの現象は発生しない。 - この現象は、Chrome の Back button を押した際の「Cache の使われ方」に起因して発生する。この部分は Formal standard が存在しないため、Browser によって挙動が違う。
- 上記の挙動は、Response Header に
Vary: Accept
をつけること、あるいはCache-Control: no-store
をつけることで回避が可能。また、今回はAccept
header に注目したが、それ以外にも request header の内容に応じて resnponse が変わる場合はVary: <対象の request header>
を指定する事が推奨される。
上記の内容について、以下で順番に説明いたします。
再現手順について
「同じ URL で Accept
request header に応じて response の Content-Type
を変えていると、Chrome で Back button を押した際の結果が意図しないものになる」というのが今回注目したい挙動です。
まずはじめに、この挙動を再現するためのデモサイトを作ってみました。以下に再現動画も載せています。動画を見て頂ければわかると思いますが、「/index
を開いた状態で、"Other Page" リンクをクリックしてから Back button をクリックして元のページに戻ると、{"message":"Hello from an index (application/json)"}
という JSON response が表示される」という挙動になっています。
Chrome の Back button では「先ほど開いたページが表示されること」を期待するので、期待とは違う挙動になっていることが分かります。
なお、デモサイトの URL および GitHub 上で公開したソースコードは以下になります。また、OS version, Chrome の version も以下にまとめています。
- デモサイトの URL: https://chrome-browserback-demo.herokuapp.com/index
- デモサイトの GitHub URL: https://github.com/south37/chrome-browserback-demo
- 実験環境
- macOS Catalina - Version 10.15.7
- Google Chrome - Version 87.0.4280.88 (Official Build) (x86_64)
何が起きているのか?
さて、それでは何が起きているのでしょうか?挙動を Chrome の Developer Tools の Network panel で確かめてみます。
まず、Chrome で Back Button を押した時に表示される GET /index
request の表示を見てみます。ここでは、Size として (disk cache)
が表示されています。どうやらページを表示する際に Cache が利用されていそうです。
実は、ここで表示されたコンテンツは「GET /index
を HTML として開いた際に、JavaScript から Fetch API で GET /index
へ JSON を request して取得した内容の Cache」となっています。つまり、「意図したもの(= /index
の HTML response)とは違う Cache」が利用されてしまっているということです。
では、なぜ「意図しない Cache」が利用されてしまっているのでしょうか?この「意図しない Cache の謎」を探りたいところですが、その前に「最初に /index
ページを開いた際に何が起きていたか」をもう少し正確に理解してみましょう。この振る舞いについては、実際にコードを見てみるほうが理解が簡単です。上記のデモサイトは以下のようなコードで動いています。
// main.go package main import ( "io" "log" "net/http" ) func main() { fs := http.FileServer(http.Dir("./static")) http.Handle("/", fs) http.HandleFunc("/index", handleIndex) http.HandleFunc("/other", handleOther) log.Fatal(http.ListenAndServe(":8080", nil)) } func handleIndex(w http.ResponseWriter, r *http.Request) { acceptMediaType := r.Header["Accept"] if len(acceptMediaType) == 1 && acceptMediaType[0] == "application/json" { handleIndexJSON(w, r) } else { handleIndexHTML(w, r) } } func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") io.WriteString(w, `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Index Page</title> <script src="/index.js"></script> <body> <div>Hello from an Index Page!</div> <a href="/other">Other Page</a> </body> </html> `) } func handleOther(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") io.WriteString(w, `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Other Page</title> <body> <div>Hello from an Other Page!</div> </body> </html> `) }
// static/index.js let myHeaders = new Headers(); myHeaders.append('Accept', 'application/json'); let myInit = { method: 'GET', headers: myHeaders, cache: 'default' }; let myRequest = new Request('index', myInit); fetch(myRequest) .then(response => console.log(response)) .catch(err => console.log(err))
簡単に挙動を説明します。まず、GET /index
の handler である handleIndex
に注目してください。ここでは、「Accept
request header の値を check して、application/json
であれば JSON response を返す handler(= handleIndexJSON
)の呼び出し、そうでなければ HTML response を返す handler(= handleIndexHTML
)の呼び出しを行う」というロジックになっています。つまり、Accept
request header の内容に応じて、response の Content-Type
を変えるという挙動になっている訳です。
handleIndexHTML
の response の HTML では、script tag として /index.js
を load して実行しています。この中身は static/index.js
の中で記述されていて、Fetch API を利用して Accept: application/json
request header 付きで GET /index
への request をしています(なお、デモなので response の内容を利用してませんが、実際にはここで JSON が描画に利用されるという挙動を想定してます)。
上記の挙動は、以下のように Developer Tools の Network panel をみると分かりやすいでしょう。まず最初に、GET /index
への request で Content-Type: text/html
の response を取得します。これが、Network panel で document
Type と記載されている request です。次に、HTML に記載された script tag で /index.js
がロードされます。これが、Network panel で script
Type と記載されている request です。最後に、/index.js
に実装された JavaScript コードによって Accept: application/json
request header 付きで GET /index
への request が行われて、Content-Type: application/json
の Response が返されます。これが、Network panel で fetch
Type と記載されている request です。
そして、"Other Page" リンクをクリックして別のページに遷移した後、Chrome の Back button を click して元のページに戻ると、「Fetch API で取得した response」が (disk cache)
として表示されます。これが、発生していた現象の一連の振る舞いです。
この挙動を回避するためにはどうしたら良いのか?
さて、この挙動を回避するには何をしたら良いでしょうか?Cache が関係しているなら、Cache に関しての設定を考え直す必要があるのでしょうか?
まず、自明な解決策としては、「同じ URL で request header に応じて異なる Content-Type
のコンテンツを返すのを避ける」という方法が考えられます。 これは実際に効果を発揮します。しかし、場合によってはこのアプローチを現実的ではないケースもあるでしょう。
そこで、Cache の設定を考え直す方法を考えます。例えば、「Cache を許可するが、常に Validation を要求する Content-Type: no-cache
」を response header として指定してみることにしましょう。これは、元々のコードに対して以下のような diff で実現することになります(注: 以下の例では Validation に利用する ETag
もつけてます。Validation を適切に扱うには If-None-Match
の handling logic も本当は追加する必要がありますが、ここでは省略しています)。
$ git diff diff --git a/main.go b/main.go index c00db5d..65c559a 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", ...) io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", ...) io.WriteString(w, `<!DOCTYPE html> <html> <head>
しかしながら、 実は Content-Type: no-cache
という header をつけても、「Chrome の Back button で意図しない Cache が利用される」という挙動を防ぐことはできません 。これは実際に動かしてもらうと分かりますが、Content-Type: no-cache
response header をつけて返した response であっても、Back button を利用した場合には (disk cache)
が利用されてしまいます。
これは、(少なくとも個人的には)直感に反する挙動です。Content-Type: no-cache
response header がついてるコンテンツは、Browser に Cache はされるものの必ず Validation が行われるはずです。つまり、 必ず If-None-Match
or If-Modified-Since
header をつけて request が行われて、server で同一性が確かめられて 304
が返ってくれば Cache が利用され、そうでなければ 200
として返った response が利用されるはずです(注: Cache validation の詳細は MDN の document である Cache validation - MDN を参照してください)。ところが、Back button で戻った場合には、そもそもこの Validation が行われません。
この挙動について調査をすると、以下の Blog や、この Blog の著者が立てた chromium の issue にたどり着きました。chromium の issue における回答では、端的に言えば「Browser の back/forward navigation は normal load ではないため、Cache validation を skip するようになっている」と説明されています。つまり、Cache validation が行われないために、Cache が無条件で利用されてしまっている訳です。
This has been filed as #516846. But since there isn't a formal standard for back/forward browser button behavior, it's hard to argue what the right behavior should be.
cf. https://www.mixmax.com/engineering/chrome-back-button-cache-no-store/
Working as intended.
Back/forward navigations retrieve stuff from the cache without revalidation.
cf. https://bugs.chromium.org/p/chromium/issues/detail?id=516846#c3
Because no-cache doesn't mean "don't cache this" (that would be no-store). no-cache means don't use this for normal loads unless the resource is revalidated for freshness. History navigations are not normal loads.
cf. https://bugs.chromium.org/p/chromium/issues/detail?id=516846#c5
さて、Cache-Control: no-cache
では「back button を押した際の意図しない Cache の利用を防げない」とすると、どうするのが良いのでしょうか?
解決方法の1つは、「Cache-Control: no-store
response header を利用する」というものです。Cache-Control: no-store
を指定している場合、chrome はそもそもその response の Cache を行いません。その為、必ず server への request が行われるようになり、その結果として Back button で戻った際に意図しない Cache が利用されるのを防ぐ事ができます。これは、上記 Blog でも説明されている方法です。実際に、以下のように Cache-Control: no-store
を指定すると、Chrome で Back button を押した際に意図通り「/index
の HTML response」が表示される事が確認できます。
$ git diff diff --git a/main.go b/main.go index c00db5d..332cd53 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") io.WriteString(w, `<!DOCTYPE html> <html> <head>
もう1つの方法が、「Vary
response header を利用する」という方法です。Vary
は「response の内容に影響を与える request header」を指定するための response header で、「Chrome などの Browser」および「CDN などの中間 Cache server」は Vary
header の内容を加味した Cache の handling が求められます。具体的には、「URL
だけでなく、Vary
header に指定した request header も一致する」ことが Cache を利用する条件になります。
実際に、以下のように Vary: Accept
response header をつけて「Accept
header も一致することを Cache 利用の条件とする」ことで、Chrome で Back button を押した際に意図通り「/index
の HTML response」が表示されることが確認できます。
$ git diff diff --git a/main.go b/main.go index c00db5d..4d0f527 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Vary", "Accept") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Vary", "Accept") io.WriteString(w, `<!DOCTYPE html> <html> <head>
実は、この「Vary
header を利用する」という方法は 上記の chromium issue で提案されている方法でもあります。Cache の恩恵を最大限受けつつ、request 内容に応じて違う response を返したい場合には Vary
header を使うと良いでしょう。
まとめ
Chrome の Back button を押した際に意図せず利用される Cache の挙動について、その再現手順と対応方法をまとめてみました。以下が、今回調査した内容のまとめになります。
- 同じ URL で
Accept
request header に応じて response のContent-Type
を変えると、Chrome では Back button を押した際に意図しないコンテンツが表示される事がある。Safari や Firefox ではこの現象は発生しない。 - この現象は、Chrome の Back button を押した際の「Cache の使われ方」に起因して発生する。この部分は Formal standard が存在しないため、Browser によって挙動が違う。
- 上記の挙動は、Response Header に
Vary: Accept
をつけること、あるいはCache-Control: no-store
をつけることで回避が可能。また、今回はAccept
header に注目したが、それ以外にも request header の内容に応じて resnponse が変わる場合はVary: <対象の request header>
を指定する事が推奨される。
個人的に、直感に反する挙動だったのが「Cache-Control: no-cache
を指定してもこの挙動を防げない」という部分です。通常の Browsing (= URL を入力しての遷移やリンクをクリックしてのページ遷移)では、Cache-Control: no-cache
header がついた response に対しては必ず Validation が行われるため、Vary
header などに気を使う必要がありません。ところが、Chrome の Back button(実は Forward button も)を利用する場合には Validation が行われないため、Vary
header を考慮する必要が出てきます。そこが自分にとっては盲点でした。
今回の調査の学びとしては、Cache-Control: no-cache
はあくまで「Cache される前提の header」である事に留意しつつ、Cache されるなら基本的には Vary
header もちゃんと設定した方が良いと言えそうです。
おまけ: Browser の Back/forward navigation における Cache の挙動の標準化について
今回、Chrome の Back button の挙動について調査をする中で、Browser の Back/forward navigation の Cache として bfcache
と呼ばれるものが存在する事がわかりました。
ここで bfcache
と呼んでいるものは、 このブログで説明した「(disk cache)
の利用」とは全く違うものです 。具体的には、「コンテンツの load や JavaScript の実行などを行った状態の page」を memory に残しておき、Back/forward navigation 時にそれを復元するという挙動のようです。
Google Chrome では、2019 年に bfcache
の実装に取り組み始めたというアナウンスが行われています。2019/2/22 の Blog Post では、 bfcache
の Demo 動画も公開されています。また、bfcache のドキュメントも公開されています。
cf. https://developers.google.com/web/updates/2019/02/back-forward-cache
cf. https://www.chromestatus.com/feature/5815270035685376
実は、bfcache
は Firefox や Safari では何年も前から実装されており、Chrome では version 79 で実装が行われたようです。しかし、Desktop の Chrome では今はデフォルトで無効化されており、chrome://flags
から有効化しなければ使えない状態です(Android では Chrome 86 から一部で有効化されたようです)。
Google implements backward-forward cache in Chrome 79 Canary
cf. https://www.ghacks.net/2019/09/20/google-implements-backward-forward-cache-in-chrome-79-canary/
bfcache has been supported in both Firefox and Safari for many years, across desktop and mobile.
Starting in version 86, Chrome has enabled bfcache for cross-site navigations on Android for a small percentage of users. In Chrome 87, bfcache support will be rolled out to all Android users for cross-site navigation, with the intent to support same-site navigation as well in the near future.
cf. https://web.dev/bfcache/#browser-compatibility
これは自分の解釈ではありますが、「Safari や Firefox は bfcache
を有効化しているのに対して、Chrome は bfcache
をデフォルトでは無効化している」事が原因で、「Chrome だけ、このブログで説明した意図しない Cache 利用が発生している」のではないかと考えられます。実際に、以下のように chrome://flags
から Back-forward cache
を Enabled force caching all pages (experimental)
にすると、今回調査に利用したデモサイトでも Chrome の Back button を押した際にちゃんと意図した挙動になることが確認できました。
また、「Back/forward navigation における Cache の挙動」に標準仕様がない事が、挙動の差異を生むそもそもの根本原因にも思えます。
実は、「bfcache
に対して標準仕様を定めようとする動き」は存在するようです。例えば、以下の issue では「Back/forward navigation における cache (= bfcache
) の opt-out の API を決めよう」という提案がなされています。また、「bfcache
の標準化の良い出発点になるかもしれない」ということにも言及されています。
Currently, we only have "implicit" ways to opt-out of bfcache that might have other side effects. A few examples from Firefox's bfcache page and problems associated with them:
...
I think having an explicit API to specifically opt-out of bfcache (and does nothing more than that) might be good, since there might be legitimate cases of not wanting to be bfcached (stale data/state of the previous page, logging in/out, etc.) specifically.
...
This might also be a good starting point into standardizing back-forward cache behavior across browsers.
cf. https://github.com/whatwg/html/issues/5744
これは、WHATWG と呼ばれる「Apple, Mozilla, Google, Microsoft などのブラウザベンダーを中心とする Web 標準を議論する団体」の、HTML Standard を議論するための repository である https://github.com/whatwg/html に対しての issue です。提案をしているのは Chrome の開発者の方のようです(注: WHATWG と W3C の関係が気になる方は HTML標準仕様の策定についてW3CとWHATWGが合意 今後はWHATWGのリビングスタンダードが唯一のHTML標準仕様に - ITmedia NEWSなどを参照してください)。
こういった標準化の動きが進み、さらに Chrome でも bfcache
がデフォルトで有効化されれば、「Chrome でのみ挙動が異なる」といったことも起きづらくなることが期待出来るでしょう。未来が楽しみですね!