Static Assets and Cache-Breaking

Browsers cache static assets aggressively. A stylesheet served once with a long Cache-Control lifetime may linger in the browser cache for days or weeks, which is exactly what you want for performance – right up until you deploy a new version of the file. At that point the browser happily keeps serving the stale copy, and your users see broken layouts, old JavaScript, or missing features until they perform a hard refresh.

The classic fix is cache-breaking (also called cache-busting): you change the URL of the asset whenever its contents change, so the browser treats it as a brand new resource and re-fetches it. Tet’s tet.static module implements this with a per-deploy token embedded into the static view’s URL prefix, so you can keep aggressive caching headers and still ship updates that take effect immediately.

This page explains how the cache-breaker works, how to wire it into your application, and how to emit cache-broken URLs from your templates.

How the cachebreaker works

When you include tet.static, a single cache-breaker token is generated once, at configuration time, and stored on the Pyramid registry as config.registry.cachebreaker. The token is a timestamp – the current time in milliseconds since the epoch, zero-padded to twelve digits:

config.registry.cachebreaker = f"{int(time.time() * 1000):012d}"

Because the token is computed once per process startup, it is effectively a per-deploy value: every fresh deployment (which restarts the application) gets a new token, and every URL generated during that deploy shares the same token. A typical token looks like 001718539201234.

The token is woven into the URL prefix of your static view rather than appended as a query string. When you register a static view with add_static_view_with_breaker(), the literal {breaker} placeholder in the view name is replaced by the current token. So a view registered as static/{breaker} actually serves assets from static/001718539201234/.... Generating a URL for an asset with Pyramid’s standard static_url() then naturally produces a URL that contains the token, with no extra work in your view code or templates.

Handling stale and premature URLs

Embedding the token in the path raises an obvious question: what happens to URLs that were generated by a previous deploy and are still cached in a user’s browser or referenced by an already-loaded page? tet.static installs a small redirector route alongside the static view to handle these cases gracefully:

  • Older token (the request’s breaker is less than the current one): the asset has been re-deployed, so the redirector issues a 301 Moved Permanently to the same asset path under the current token. The browser follows the redirect and picks up the fresh asset.

  • Newer token (the request’s breaker is greater than the current one): this happens transiently during a rolling deploy, when a page served by a newer instance points at assets that an older instance has not caught up with yet. The redirector returns 503 Service Unavailable with a Retry-After: 3 header so the client retries shortly.

  • Matching token but no such asset: a plain 404 Not Found.

You do not have to configure any of this – registering the view with add_static_view_with_breaker() wires up the redirector automatically.

Wiring it into your application

Pull the module in with config.include and then register your static views using the add_static_view_with_breaker() directive. The include call adds two directives to the Configurator: set_cachebreaker() and add_static_view_with_breaker().

from tet.config import application_factory


@application_factory()
def main(config):
    config.include("tet.static")
    config.add_static_view_with_breaker(
        name="static/{breaker}",
        path="myapp:static",
    )
    config.scan()

The name argument must contain the literal {breaker} placeholder; if it is missing, add_static_view_with_breaker() raises a ValueError. The path argument is an ordinary Pyramid asset specification pointing at the directory that holds your static files. Any extra keyword arguments are forwarded to the underlying add_static_view(), so you can pass options such as cache_max_age:

config.add_static_view_with_breaker(
    name="static/{breaker}",
    path="myapp:static",
    cache_max_age=31536000,  # one year -- safe, because the URL changes per deploy
)

Because each deploy mints a new token and therefore a new URL, you can set a very long cache_max_age without ever risking a stale asset. That is the whole point of cache-breaking: maximum caching, zero staleness.

Overriding the token

The default timestamp token is convenient, but sometimes you want a deterministic value – for example, to tie the token to a release tag, a Git commit hash, or a value injected by your CI pipeline so that several processes in a cluster all agree on the same token. Use the set_cachebreaker() directive before registering your static views:

@application_factory()
def main(config):
    config.include("tet.static")
    config.set_cachebreaker(config.registry.settings["myapp.release"])
    config.add_static_view_with_breaker(
        name="static/{breaker}",
        path="myapp:static",
    )
    config.scan()

set_cachebreaker() simply replaces config.registry.cachebreaker with the value you supply. Since add_static_view_with_breaker() reads the registry at the moment it builds the URL, the override only takes effect for views registered afterward – so always set it first.

Using static_url in templates

Once the cache-broken static view is registered, you do not need to think about the token in day-to-day template work. Generate asset URLs the normal Pyramid way, with request.static_url(), passing the same asset specification style you used for path. The generated URL automatically includes the current cachebreaker token because it is baked into the view’s URL prefix.

In a Tonnikala template, use $ interpolation – the expression resolves the chain of attribute access and the method call as a single unit:

<link href="$request.static_url('myapp:static/style.css')" rel="stylesheet">
<script src="$request.static_url('myapp:static/app.js')"></script>
<img src="$request.static_url('myapp:static/img/logo.png')" alt="Logo">

At render time these expand to URLs such as:

/static/001718539201234/style.css
/static/001718539201234/app.js
/static/001718539201234/img/logo.png

After the next deploy the token changes, every emitted URL changes with it, and browsers fetch the new assets – while any page still pointing at the old token gets a 301 redirect to the current one.

You can of course use the same call from Python view code or any other renderer:

@view_config(renderer="myapp:templates/index.tk")
def index(request):
    return {
        "stylesheet_url": request.static_url("myapp:static/style.css"),
    }

Note

Always go through request.static_url() (or its template equivalent) rather than hard-coding the /static/... path yourself. Hard-coded paths will not contain the cachebreaker token, defeating the whole mechanism, and will break if the token-bearing prefix ever changes.

See also

  • Templating with Tonnikala – writing Tonnikala templates and using $ interpolation, including expressions like $request.static_url(...).

  • Configuration – the application factory, config.include and registering directives during configuration.