Chrome の Back button を押した際に「意図しない Cache」が利用されて、期待と違うページが表示される問題について調査した

以前、同僚と話していた際に「同じ URL で Accept request header に応じて response の Content-Type を変えていると、Chrome で Back button を押した際の結果が意図しないものになる」ということが話題になりました。

これは、自分も過去に経験した事があり、不思議に思っていた部分です。SafariFirefox では発生せず、Chrome だけで発生するというのもポイントです。この挙動について、今回は調査してみました。

TL; DR

今回の調査内容を簡潔にまとめると、以下のようになります。

  • 同じ URL で Accept request header に応じて response の Content-Type を変えると、Chrome では Back button を押した際に意図しないコンテンツが表示される事がある。SafariFirefox ではこの現象は発生しない。
  • この現象は、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 も以下にまとめています。

何が起きているのか?

さて、それでは何が起きているのでしょうか?挙動を ChromeDeveloper Tools の Network panel で確かめてみます。

まず、Chrome で Back Button を押した時に表示される GET /index request の表示を見てみます。ここでは、Size として (disk cache) が表示されています。どうやらページを表示する際に Cache が利用されていそうです。

f:id:south37:20210111002027p:plain

実は、ここで表示されたコンテンツは「GET /index を HTML として開いた際に、JavaScript から Fetch APIGET /indexJSON を 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 です。

f:id:south37:20210111010051p:plain

そして、"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 を押した際に意図しないコンテンツが表示される事がある。SafariFirefox ではこの現象は発生しない。
  • この現象は、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

実は、bfcacheFirefoxSafari では何年も前から実装されており、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

これは自分の解釈ではありますが、「SafariFirefoxbfcache を有効化しているのに対して、Chromebfcache をデフォルトでは無効化している」事が原因で、「Chrome だけ、このブログで説明した意図しない Cache 利用が発生している」のではないかと考えられます。実際に、以下のように chrome://flags から Back-forward cacheEnabled force caching all pages (experimental) にすると、今回調査に利用したデモサイトでも Chrome の Back button を押した際にちゃんと意図した挙動になることが確認できました

f:id:south37:20210111015316p:plain

また、「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 の開発者の方のようです(注: WHATWGW3C の関係が気になる方は HTML標準仕様の策定についてW3CとWHATWGが合意 今後はWHATWGのリビングスタンダードが唯一のHTML標準仕様に - ITmedia NEWSなどを参照してください)。

こういった標準化の動きが進み、さらに Chrome でも bfcache がデフォルトで有効化されれば、「Chrome でのみ挙動が異なる」といったことも起きづらくなることが期待出来るでしょう。未来が楽しみですね!