Skip to content

Versioning and Cache Busting

HyperDjango provides a layered asset versioning system that ensures clients always load the correct version of static files, detects stale-client conditions during HTMX-boosted navigation, and supports deployment strategies like blue/green and canary releases.

Architecture

Three layers, each building on the one below:

  1. Per-file content hashing — each static file gets a content-hash filename (styles.a1b2c3d4e5f6.css) via ManifestStaticFilesStorage. Files with hashed names are cached for one year with Cache-Control: immutable.

  2. App-level version — a single hash derived from all static assets (the manifest hash field) or explicitly set. Exposed as X-App-Version header, template global, and /version endpoint.

  3. Version routing — clients send their expected version in a request header; middleware routes to the correct backend or signals a mismatch.

Quick Start

from hyperdjango import HyperApp
from hyperdjango.standalone_middleware import VersionMiddleware

app = HyperApp(title="MyApp", static="static")

# Add version header + HTMX mismatch detection
app.use(VersionMiddleware())

# Mount /version endpoint + /cache/bust
app.mount_version()

In templates:

{# Production: content-hash filename (already cache-safe) #}
<link rel="stylesheet" href="{{ static('css/styles.css') }}" />

{# Dev mode: appends ?v=hash from file content #}
<link rel="stylesheet" href="{{ static_url('css/styles.css') }}" />

{# App version for manual use #}
<meta name="app-version" content="{{ app_version() }}" />

Development Mode

In development (no collectstatic), the static_url() template helper appends a ?v=<content_hash> query parameter computed from the file's actual bytes:

/static/css/styles.css?v=a1b2c3d4e5f6

When the file content changes, the hash changes, so browsers fetch the new version. An mtime-based cache avoids re-hashing on every template render.

Controlled by STATIC_DEV_VERSION_QUERY (default: True).

Production Mode

Run collectstatic to generate content-hash filenames and a manifest:

uv run hyper collectstatic --static-dirs static --static-root staticfiles

This produces:

  • staticfiles/css/styles.a1b2c3d4e5f6.css — hashed filename
  • staticfiles/staticfiles.json — manifest mapping originals to hashed names

The manifest includes a top-level hash field derived from all file hashes. This becomes the app-level version automatically.

Both {{ static('path') }} and {{ static_url('path') }} resolve to the hashed filename in production.

X-App-Version Header

When APP_VERSION_HEADER is True (default), every HTTP response includes:

X-App-Version: a1b2c3d4e5f6

The version is resolved in this order:

  1. Explicit APP_VERSION setting (git SHA, semver, etc.)
  2. Manifest hash from staticfiles.json
  3. Computed hash from registered component files
  4. Fallback: "unknown"

HTMX Version Mismatch Detection

HTMX-boosted navigation replaces page content without reloading the full page. If the server deploys a new version mid-session, the client's JavaScript and CSS become stale.

VersionMiddleware injects a script into HTML responses that listens for htmx:afterRequest events and compares the X-App-Version response header against the page's window.__hyperAppVersion. On mismatch:

  • "reload" (default) — forces window.location.reload()
  • "warn" — logs to console.warn() without reloading
  • "ignore" — disables script injection entirely

Controlled by APP_VERSION_MISMATCH setting.

Version Endpoint

app.mount_version() registers two endpoints:

GET /version

Returns app version metadata:

{
  "version": "a1b2c3d4e5f6",
  "source": "manifest",
  "components": {
    "templates": 15,
    "config": 3
  }
}

POST /cache/bust

Manual cache invalidation for deploy scripts. Requires an auth token:

# Generate token from SECRET_KEY
TOKEN=$(python -c "
from hyperdjango.versioning import _make_cache_bust_token
print(_make_cache_bust_token())
")

curl -X POST https://myapp.com/cache/bust \
  -H "Authorization: Bearer $TOKEN"

Response:

{ "status": "ok", "version": "new_hash_here" }

Component Registration

Register non-static files that contribute to the app version:

app.register_version_component("templates", [
    "templates/base.html",
    "templates/nav.html",
])
app.register_version_component("config", [
    "config/production.json",
])

The app version is derived from both the static manifest hash and the registered component file hashes.

Blue/Green and Canary Deployments

VersionRouterMiddleware supports version-based request routing:

from hyperdjango.standalone_middleware import VersionRouterMiddleware

app.use(VersionRouterMiddleware(
    version_map={
        "v1.0": "backend-blue",
        "v1.1": "backend-green",
    },
    default_version="v1.1",
))

The middleware:

  1. Reads X-App-Requested-Version from the request header (or app_version cookie)
  2. If the version is in version_map, sets x-backend-target on the response
  3. If the version is unknown, returns 409 Conflict with available versions
  4. The upstream proxy (nginx, Envoy) reads x-backend-target for routing

Nginx example:

location / {
    proxy_pass http://app;
    proxy_set_header X-App-Requested-Version $http_x_app_requested_version;

    # Route based on response header (requires post-processing)
    # Or use Lua/njs to read the header and re-route
}

Settings Reference

Setting Type Default Description
APP_VERSION str "" Explicit version (git SHA, semver). Empty = auto-compute
APP_VERSION_HEADER bool True Emit X-App-Version on all responses
APP_VERSION_MISMATCH str "reload" Client action: "reload", "warn", "ignore"
VERSION_ENDPOINT bool True Mount /version when mount_version() called
STATIC_DEV_VERSION_QUERY bool True Append ?v=hash in dev mode

Design Principles

These rules should be followed by any subsystem that needs caching or versioning:

  1. One manifest, not several unrelated version stores. All version information flows through AppVersion.

  2. Version actual bytes. Hash the final emitted content (compiled CSS, minified JS, serialized JSON), not filenames, mtimes, or deployment timestamps.

  3. Derive parent version from child versions. The app-level version is derived from per-file hashes, so one freshness signal answers "is the currently loaded runtime still valid for this response?"

  4. Rewrite embedded asset refs centrally. Templates write canonical paths (css/styles.css); the renderer injects versioned URLs. Authors never hardcode version query params into source content.

  5. Manual bust APIs are authoritative. Cache refreshes happen after coherent operator-triggered mutations (deploys), not during half-finished edits or via background file watchers.

  6. Distinguish canonical paths from cacheable paths. Canonical path = stable identifier. Versioned path = cacheable delivery path.

  7. Make stale-client detection explicit. If partial-page navigation exists (HTMX), emit a version header and reload on mismatch.