You Do Not Understand Browser History
Wow! This takes me back! Please check the date this post was authored, as it may no longer be relevant in a modern context.
More than SSL, more than CrossOrigin Request headers, I’m staring to think the back button must be the most mis-understood part of how browsers implement HTTP.
The web’s history feature is built with a specific usage in mind. Modern browsers take the HTTP 1.1 spec section 13.13 as a starting point for implementation (emphasis added):
13.13 History Lists
User agents often have history mechanisms, such as “Back” buttons and history lists, which can be used to redisplay an entity retrieved earlier in a session.
History mechanisms and caches are different. In particular history mechanisms SHOULD NOT try to show a semantically transparent view of the current state of a resource. Rather, a history mechanism is meant to show exactly what the user saw at the time when the resource was retrieved.
By default, an expiration time does not apply to history mechanisms. If the entity is still in storage, a history mechanism SHOULD display it even if the entity has expired, unless the user has specifically configured the agent to refresh expired history documents.
This is not to be construed to prohibit the history mechanism from telling the user that a view might be stale.
Appended is this weird set of concerns from 1999 that led to this behavior. Or something. Honestly, I cannot parse it.
Note: if history list mechanisms unnecessarily prevent users from viewing stale resources, this will tend to force service authors to avoid using HTTP expiration controls and cache controls when they would otherwise like to. Service authors may consider it portant that users not be presented with error messages or warning messages when they use navigation controls (such as BACK) to view previously fetched resources. Even though sometimes such resources ought not to cached, or ought to expire quickly, user interface considerations may force service authors to resort to other means of preventing caching (e.g. “once-only” URLs) in order not to suffer the effects of improperly functioning history mechanisms.
The takeaway, relevant to any kind of app, is that browsers should not be expected to honor HTTP caches headers when moving through a user’s history. It doesn’t matter if you are building a single-page app or traditional multi-page app with a spattering of dynamic behavior, understand this: The browser does not respect HTTP caching rules when you click the back button. Clicking the back button will load a webpage in a completely different way, and with a different intent, than if you load the same page via a link.
In practice
What does this mean in practice? Fancyremaker and I did some research, the result of which is this demo page:
An HTML page is first loaded, which then fetches three json resources upon pageload. Each JSON endpoint responds with a random hex value. The first, no-cache, has the cache headers:
Cache-Control: must-revalidate, no-cache, private
Pragma: no-cache
Etag: "((the hex value))"
These headers express a very conservative cache plan. Basically: Don’t cache. Despite this, Chrome and other browsers may still store the response.
The spec reads “If the entity is still in storage, a history mechanism SHOULD display it even if the entity has expired”. Using Chrome, if you click either link below the hashes (to a page on the same domain, or to another domain) then click back, this hash will remain the same. If you look at the “size” column in the network tab of Chrome you will see “(from cache)” there.
The second hex value is served with the no-store header value.
Cache-Control: must-revalidate, no-store, no-cache, private
Pragma: no-store, no-cache
Etag: "((the hex value))"
If you click the link to the same domain, the hash will remain the same when you click back. This seems potentially wrong- we’ve told the browser not to store the response, but the network tab makes it look like this response is cached. If you click the link to another domain, the hash will change when you click back. Since the response is not stored, the browser has no choice but to fetch the resource anew.
The third is requested with jQuery’s cache: false
setting. This setting adds a timestamp to the GET parameters of each request, busting any cache the browser may have of that resource. In Chrome, it too refreshes on each return to the page.
But The Rabbit Hole Goes Deeper
Safari and Firefox implement something we can call the “bfcache” (back-forward cache). This cache stores the current state of the DOM in memory. When a page is re-visited via the back button, no requests are made. The DOM itself is served straight up from cache.
There is a less-than-subtle difference in these behaviors. Given a long-lived single page app, the data returned in the API requests to initially draw the page may be quite out of date. The DOM is at least guaranteed to approximate what your application looked like when the page was left.
Setting an unload
handler on the original page will keep Safari and Firefox from storing the DOM in their bfcache.
$(window).unload(function(){}); // Does nothing but break the bfcache
Here is an example on the demo app. With this behavior, Firefox reloads the no-store
and cache busted responses. Safari disregards the no-store
header and only reloads the cache busted response.
In some ways, the original behavior of Safari and Firefox may actually be preferable. At other times, you may want to break with the spec’s suggestions of non-semantic history and ensure that your AJAX queries actually go through.
Trouble on the horizon
The last time the web developer community thought about what the back button does was in the HTML(5) spec. There we finally solidified a JavaScript API for inspecting and changing browser history.
But that review didn’t touch upon what the browser does between pages, or how history and HTTP cache headers interact. SPDY and HTTP 2.0 dodge the issue, not mentioning history once between them. With the growing presence of single-page apps, what users expect from the back button and what developers are trying to achieve is surely changing.
Today, the only option for ensuring an XHR request is made when the user re-visits a page via the back button is to (1) add an unload handler then (2) use cache busting. This is also the only way to ensure these three browsers behave consistently (and I won’t speak on IE here). Using this method is a huge tradeoff though- You can no longer use etag or max-age header values, so every request must be fetched from your server and the content streamed back to the client. This is devastating to most apps.
Often, your only option is to code defensively on the client. Despite what XHR may tell your JS code, the resources returned from the server may not actually be present. Build with that assumption. Ensure you catch errors and display them to users in a comprehensible way.
In the future, I hope to see optimizations of the browser history feature matched by progress in new tools for developers. It feels like the behavior of history has become completely mis-aligned with what developers intend. I remain un-convinced that the spec is still relevant to apps and users (just ask my mother if she wants a semantic version of the last page). In the vein of Yehuda’s Extend the Web Forward, I would be pleased to see new headers that help us change and experiment with what history means to our users.