Handling bfcache Limitations in ASP.NET Core Forms with Petite-Vue
Lee Timmins
19th Feb 2026
Comments
Back/forward navigation can expose subtle interactions between frontend state and server-side caching policies. This is particularly noticeable when using petite-vue (and likely applies in the same way to Alpine.js) together with a form that has anti-forgery enabled in ASP.NET Core.
Consider the following petite-vue example:
<div id="test" v-scope="{ 'count': 1 }" @vue:mounted="$el.$data = $data">
<input type="text" v-model="count" />
<button @click="count++">+</button>
</div>
<script>
window.addEventListener('pageshow', e => {
console.log('pageshow', e.persisted);
console.log('$data', document.getElementById('test').$data);
});
</script>
In a typical static environment, this behaves as expected. Increment the count, navigate away, then click the browser’s Back button. The pageshow event logs persisted: true, indicating the page was restored from the browser’s back-forward cache (bfcache). The $data object still reflects the updated count value, because the entire page state was preserved.
The behavior changes when using a form with anti-forgery enabled in ASP.NET Core. In that case, the framework sets the following response header:
Cache-Control: no-cache, no-store
The critical directive is no-store. This prevents the browser from placing the page into the bfcache. As a result, when you increment the count, navigate away, and return, the browser performs a full reload rather than restoring a cached snapshot.
In this case:
- pageshow logs persisted: false.
- The petite-vue $data object resets to its initial state (count: 1).
- The input field value may still appear restored, because form value restoration during history navigation is handled separately from the bfcache.
This leads to a mismatch: the DOM input may show the incremented value, but the underlying reactive state has reverted to defaults.
One possible workaround is adding autocomplete="off" to the form or its inputs. This disables the browser’s form value restoration. However, this approach also removes autocomplete functionality, which may still be desirable.
A more robust approach is to avoid relying on the bfcache entirely and instead persist application state explicitly using sessionStorage.
For example:
<div id="test" v-scope="{ 'count': 1 }" @vue:mounted="$el.$data = $data">
<input type="text" v-model="count" />
<button @click="count++">+</button>
</div>
<script>
const test = document.getElementById('test');
window.addEventListener('pagehide', e => {
sessionStorage.setItem('count', test.$data.count);
});
window.addEventListener('pageshow', e => {
console.log('pageshow', e.persisted);
const saved = sessionStorage.getItem('count');
if (saved !== null)
test.$data.count = saved;
console.log('$data', test.$data);
});
</script>
Here's what changes:
- On pagehide, the current reactive value is written to sessionStorage.
- On pageshow, the value is restored into petite-vue’s $data.
- The logic works regardless of whether the page was restored from bfcache or reloaded from the server.
- There is no reliance on browser caching semantics.
- Autocomplete functionality can remain enabled.
This approach separates application state from browser caching behavior. Instead of assuming the browser will preserve a snapshot of the page, the application takes explicit control over state persistence. That makes the behavior consistent across environments, including those where Cache-Control: no-store is required for security reasons.
When server-side frameworks enforce strict cache headers for security, frontend code that depends on the bfcache can fail in subtle ways. Explicitly persisting state in sessionStorage (or another storage mechanism appropriate for the scenario) avoids these inconsistencies and keeps client-side state predictable.