Triggering Updates in Stale Bundles
As we’ve been doing more Vue apps with Django backends, we have had to figure out the best way to get client browsers to update when we release new versions, especially when there are backwards incompatible changes to the API.
Introduction
When building a backend-heavy application in Django using server rendered templates, you largely don’t have to worry about caching on the client side. This is because every interaction with your application is making requests via the browser. In a Vue app, you are making requests to fetch data via an API while all the logic for presentation lives within bundles that get cached in your user’s browser.
When a new version of the application is released with backwards-incompatible API changes, users may be working with a previous version of the bundle persisted in their browser. When the stale bundle attempts to access the API, it is likely that user will encounter a cascade of JavaScript errors and have an unpredictable experience using the application.
Problem in Detail
Let’s take a look a bit deeper at this problem with a concrete example.
api.py
def bookmarks(request):
data = [b.data() for b in Bookmark.objects.all()]
return JSONResponse(data=dict(bookmarks=data))
App.vue
<template>
<div class="bookmark-list">
<button @click="refresh">Refresh</button>
<div v-for="(bookmark, index) in bookmarks" :key="index">
<a :href="bookmark.url">{{ bookmark.title }}</a>
</div>
</div>
</template>
<script>
import api from './api';
export {
data() {
return {
bookmarks: [],
}
},
methods: {
refresh() {
api.getBookmarks(data => {
this.bookmarks = data.bookmarks;
});
}
},
created() {
this.refresh();
}
}
</script>
models.py
class Bookmark(models.Model):
title = models.CharField(max_length=250)
url = models.CharField(max_length=500)
def data(self):
return dict(title=self.title, url=self.url)
Now after running with this for awhile you discover that everyone is seeing everyone else’s bookmarks and you want to segment by user so you make the following changes:
api.py
@login_required
def bookmarks(request):
data = [b.data() for b in Bookmark.objects.filter(user=request.user)]
return JSONResponse(data=dict(bookmarks=data))
App.vue
<template>
<div class="bookmark-list" v-if="authed">
<button @click="refresh">Refresh</button>
<div v-for="(bookmark, index) in bookmarks" :key="index">
<a :href="bookmark.url">{{ bookmark.title }}</a>
</div>
</div>
<div class="auth" v-else>
<AuthForm />
</div>
</template>
<script>
import api from './api';
import AuthForm from './AuthForm.vue';
export {
components: { AuthForm },
data() {
return {
bookmarks: [],
}
},
methods: {
refresh() {
api.getBookmarks(data => {
this.bookmarks = data.bookmarks;
});
}
},
computed: {
authed() { ... },
},
watch: {
authed: {
immediate: true,
handler() {
if (this.authed) {
this.refresh();
}
}
}
}
}
</script>
models.py
class Bookmark(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
title = models.CharField(max_length=250)
url = models.CharField(max_length=500)
def data(self):
return dict(title=self.title, url=self.url)
Now, in order to use the app, you need to be authenticated so that the bookmark list can be filtered by your user. The backend expects this. The new frontend component is updated to expect this.
However, after the version has been deployed, you start getting error reports from people who still have the old bundle in their web browser and they click the refresh button after you’ve deployed the update. This is because they still have the old App.vue
and it’s making a call to the the API endpoint for bookmarks that has now changed to require authentication.
What we need is a way to tell the client that the bundle is stale and must be updated in order to continue using the application. There are several approaches to this problem.
Options
The fanciest option is to build a Progressive Web App with a service worker and the associated bells and whistles for self updating and more. I’ve looked into this before and seemed like a lot of work to retrofit an existing application but might be the best practice if starting a new application today. There does seem to be added complexity in grappling with browser differences and testing.
You could also version the API by prefixing the URL paths in the API with something like /v1/api/
, /v2/api/
, and incrementing the prefix every time there is a backwards incompatible change. If you did this, then you would also build in the extra handling on the server side to support API views for each version, turning them off when you detect there is no longer any traffic to them. This is probably the most robust solution, and also provides the most seamless experience for your users, but it may also slow active development down to a crawl. It probably makes sense for some applications but for most it’s probably overkill.
Finally, you can communicate the bundle version with every API call and compare it with the version on the server. If the versions don't match, you can respond with a special code that tells the stale bundle to alert the user that a new version is available. The user could be presented with a button to obtain the new version, and upon clicking this, force a refresh to get the new bundle.
It’s an interruptive experience for the user, but better than errors and unknown behavior. If done right, it can actually make the user feel good. I know I always love it when the web apps I use present me with a notification that new updates have been released. As a bonus, this solution scales. It’s the same amount of work to implement whether you have a few endpoints or hundreds.
Solution in Detail
Let’s expand on our earlier example. Overall what we are accomplishing is having the client add a header value to every API request that lets the server know what version is being used to make the call. We pick up that version from the environment when the bundle is delivered to the client. For example, for applications hosted on Heroku, you could enable this by enabling Dyno Metadata and read HEROKU_SLUG_COMMIT
from the environment. Then on the server, we want to have every API view first check if the version matches the latest and if it doesn’t we send code to the client to respond to. In this case, we use the unused 418
code so to avoid confusion with other status codes that have meaning.
context_processors.py
# If you are deployed on Heroku you can get the unique version
# of what's deployed from the environment
#
# https://devcenter.heroku.com/articles/dyno-metadata
def settings(request):
return dict(
SOURCE_VERSION=os.environ.get("HEROKU_SLUG_COMMIT", "")
)
app.html
<html>
<head>
<!-- Put this in the head of your base template -->
<script>
window.AppConfig = {
SOURCE_VERSION: '{{ SOURCE_VERSION }}',
}
</script>
</head>
<body>...</body>
</html>
api.js
import axios from 'axios';
const { SOURCE_VERSION } = window.AppConfig;
const versionHeaders = (config) => {
config.headers['X-App-Version'] = SOURCE_VERSION;
return config;
};
const HTTP = axios.create({
xsrfHeaderName: 'X-CSRFToken',
xsrfCookieName: 'csrftoken',
});
HTTP.interceptors.request.use(versionHeaders, error => Promise.reject(error));
export default {
getBookmarks: (cb) => HTTP.get('bookmarks/').then(response => cb(response.data)),
};
api.py
import os
from django.http import JsonResponse
from django.views import View
from .models import Bookmark
class JsonResponseAuthError(JsonResponse):
status_code = 401
class JsonResponseInvalidVersionError(JsonResponse):
status_code = 418
class APIView(View):
def validate_version(self, request):
version = request.META.get("HTTP_X_APP_VERSION", "")
if version == "":
return True
return os.environ.get("HEROKU_SLUG_COMMIT", "") == version:
@property
def user(self):
return self.request.user
def dispatch(self, request, *args, **kwargs):
if self.validate_version(request) is False:
return JsonResponseInvalidVersionError(data={"error": "New version available."})
if not request.user.is_authenticated:
return JsonResponseAuthError(data={"error": "Authentication required"})
return super().dispatch(request, *args, **kwargs)
class Bookmarks(APIView):
def get(self, request, *args, **kwargs):
data = [b.data() for b in Bookmark.objects.all()]
return JSONResponse(data=dict(bookmarks=data))
App.vue
<template>
<div class="version-update" v-if="versionUpdate">
<p>There is a new version!</p>
<button @click="getNewVersion">Get Update</button>
</div>
<div class="bookmark-list" v-else-if="authed">
<button @click="refresh">Refresh</button>
<div v-for="(bookmark, index) in bookmarks" :key="index">
<a :href="bookmark.url">{{ bookmark.title }}</a>
</div>
</div>
<div class="auth" v-else>
<AuthForm />
</div>
</template>
<script>
import api from './api';
import AuthForm from './AuthForm.vue';
export {
components: { AuthForm },
data() {
return {
bookmarks: [],
versionUpdate: false,
}
},
methods: {
getNewVersion() {
window.location.reload(true);
},
refresh() {
api.getBookmarks(data => {
this.bookmarks = data.bookmarks;
}).catch(error => {
if (error.response && error.response.status === 418) {
this.versionUpdate = true;
} else {
throw error;
}
});
}
},
computed: {
authed() { ... },
},
created() {
this.refresh();
}
}
</script>
The first thing you’ll want to do is setup a context processor to pull the version from your environment (or however you manage to get access to a deployment version, doesn’t really matter what it is as long as it’s a unique value for each deployment).
Then we set an object on window
so it’s globally available within our Vue app. You will likely find this pattern useful for a host of other settings, so scoping it to a single object that you can expand on later is better than creating settings directly on the window
object.
Once we have this version, we want to setup axios
to use a request interceptor so as we add other API functions, we don’t have to worry about the header construction.
Lastly, when we make API calls, we’ll want to catch and check for any 418
errors so we can present the user a notice that the bundle is now out-of-date and present a way for them to refresh the application.
Summary
As your site gains wider adoption and has more active users, you may find yourself pushing out more frequent releases to squash bugs and introduce new enhancements. As that happens, you’ll invariably run into mismatches between stale client bundles in the user's browser and the most recent release of the backend API. Sending the client version to the server with each request allows the server to inform the client when it is out of date and in turn allows the client to inform the user that a refresh is required.
You could also adapt this pattern so that the server keeps track of what response is expected by prior client versions, but this is probably over-engineering for most use cases.