djust docs
Browse documentation
advanced

Service Worker: Instant Shell + Reconnection Bridge

Opt-in SW features for SPA-style instant navigation and resilient WebSocket delivery across brief disconnects.

On this page

djust ships a small, opt-in service worker (SW) that adds two reliability / perceived-performance features without changing how you write views:

  1. Instant page shell — on every navigation, the SW serves a cached HTML shell (<head>, <nav>, <footer>) immediately, while the client fetches only the fresh <main> contents from the server. The user sees the chrome before the network response returns.
  2. WebSocket reconnection bridge — when the WebSocket is briefly disconnected, outgoing events are buffered in the SW (in-memory, per connection) instead of being dropped. On reconnect they are replayed in order.

Both features are opt-in and independent. Neither is active unless you register the SW explicitly.

Status: The reconnection buffer is in-memory only (IndexedDB persistence is not implemented). Shell/main extraction uses a regex — see Limitations.


When to use it

  • Instant shell pays off on content-heavy sites where <head> + <nav> + <footer> account for a noticeable chunk of time-to-first-paint. It is a perceived-latency win, not a throughput win.
  • Reconnection bridge pays off on flaky mobile / office-WiFi connections where the user's click-burst often straddles a brief WS drop. Without it, those clicks are silently lost.

If your users are on LAN with a perfect WebSocket, neither feature is needed.


Setup

1. Register the service worker from your init code

The SW file is served at /static/djust/service-worker.js — no routing or Django view needed, it's shipped as a static asset.

Somewhere in your page's init JS (e.g. a <script> tag in base.html, after client.js):

<script>
    window.addEventListener('load', function () {
        djust.registerServiceWorker({
            instantShell: true,
            reconnectionBridge: true,
        }).then(function (reg) {
            if (reg) console.log('[djust] SW registered');
        });
    });
</script>

All options are individually toggleable:

OptionDefaultEffect
instantShellfalseEnable the cached-shell navigation fast path.
reconnectionBridgefalseEnable outgoing-message buffering during WS disconnect.
vdomCachefalse(v0.6.0) Cache VDOM HTML per URL; serve instantly on popstate before reconciling against the live mount reply.
stateSnapshotfalse(v0.6.0) Persist a view's public state on djust:before-navigate; restore via _restore_snapshot() instead of running mount() on back-nav.
swUrl/static/djust/service-worker.jsOverride if you serve the SW from a custom path.
scope/Override if the SW should only manage a subtree.

If navigator.serviceWorker is unavailable (old browser, private-mode edge case) all features gracefully no-op. registerServiceWorker(...) resolves to null.

2. Add the main-only middleware (required for instantShell)

Instant shell needs the server to respond with only the <main> element's inner HTML when the client sends X-Djust-Main-Only: 1. Enable it by adding the middleware to your Django settings:

# settings.py

MIDDLEWARE = [
    # ... your existing middleware ...
    "djust.middleware.DjustMainOnlyMiddleware",
]

The middleware is mostly ordering-safe — it only reads the request header and trims response.content on the way out. It:

  • Passes through non-HTML responses (JSON, binary) unchanged.
  • Passes through requests that don't carry the header.
  • Updates Content-Length after trimming.
  • Sets X-Djust-Main-Only-Response: 1 on transformed responses so clients can distinguish them.
  • Leaves streaming responses untouched.

⚠ Ordering caveat with GZipMiddleware. If you use Django's GZipMiddleware, place DjustMainOnlyMiddleware below it in the MIDDLEWARE list, so it trims the response before GZip compresses it. MIDDLEWARE executes in reverse order on responses, so "below" = "runs earlier on responses." If the middleware sits above GZipMiddleware, it sees already-compressed bytes, fails to decode them as UTF-8, and passes the full page through untrimmed — the shell client then puts a whole page inside <main>. Rule of thumb: any middleware that modifies response.content must run before any middleware that encodes/compresses it.

3. Ensure your layout has a <main> element

The SW splits HTML into "shell" (everything outside <main>) and "main" (inside). If your base template has no <main>, the SW silently skips caching (the first-load user just sees the normal response). A minimal example:

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}My App{% endblock %}</title>
</head>
<body>
    <nav>…</nav>

    <main>
        {% block content %}{% endblock %}
    </main>

    <footer>…</footer>
    <script src="{% static 'djust/client.js' %}"></script>
</body>
</html>

How it works

Instant shell — navigation flow

First navigation:
  Browser  --nav GET /page/--> SW --passthrough--> Django
                                                   returns full HTML
  Browser <------- full HTML (unchanged) -------- SW (caches split shell)

Subsequent navigation:
  Browser  --nav GET /page/--> SW
                               |
                               └-- returns cached shell with
                                   <main data-djust-shell-placeholder="1"></main>

  Client JS sees the placeholder, fires:
  Browser  --fetch /page/, X-Djust-Main-Only: 1--> Django
                                                   DjustMainOnlyMiddleware
                                                   trims response to <main> inner
  Browser  <--- fresh <main> inner HTML ---

  Client swaps the placeholder's innerHTML with the fresh content.

Reconnection bridge — message flow

WS OPEN:    client.ws.send(message) --> server (normal path)

WS CLOSED:  client.ws.send(message)
              |
              └-- sendMessage() detects readyState != OPEN
                  |
                  └-- postMessage({type: DJUST_BUFFER, connectionId, payload}) --> SW
                                                                                   |
                                                                                   └-- RECONNECT_BUFFER.get(connectionId).push(payload)

WS re-OPEN: client dispatches "djust:ws-open"
              |
              └-- postMessage({type: DJUST_DRAIN, connectionId}) --> SW
              |                                                      |
              |                                                      └-- delete & reply
              |
              └-- SW postMessage({type: DJUST_DRAIN_REPLY, messages: [...]}) --> client
                                                                                 |
                                                                                 └-- replay each via ws.ws.send(raw)

Buffered messages are capped at 50 per connection (oldest dropped). Buffers are in-memory in the SW process — an SW restart (browser shutdown, SW replaced by a newer version) loses them.


Advanced features (v0.6.0)

Three opt-in optimizations layered on top of the v0.5.0 core. All are off by default — enable per-feature on registerServiceWorker(...) and (for state snapshots) per-view via a class attribute. Each one gracefully no-ops when its preconditions aren't met, so partial adoption is always safe.

VDOM patch cache

When the user hits the back or forward button, the SW serves a cached HTML snapshot of the destination URL immediately, then the live WebSocket mount reply reconciles any drift via the normal VDOM patch path. The user sees content the instant the route changes; the network round-trip is hidden behind the perceived paint.

Enable it in the registration call:

djust.registerServiceWorker({ vdomCache: true });

The DJUST_VDOM_CACHE_* settings below are validated by the C301–C303 system checks, but they are not currently sent to the service worker: the SW uses its built-in defaults (30-minute TTL, 50 entries), which happen to match the setting defaults.

# settings.py
DJUST_VDOM_CACHE_ENABLED = True
DJUST_VDOM_CACHE_TTL_SECONDS = 1800        # default — entries expire after 30 min
DJUST_VDOM_CACHE_MAX_ENTRIES = 50          # default — LRU evict beyond this cap

Three system checks guard the configuration ranges so a typo (e.g. negative TTL) fails fast at manage.py check:

Check IDSeverityFires when
djust.C301errorDJUST_VDOM_CACHE_TTL_SECONDS ≤ 0
djust.C302errorDJUST_VDOM_CACHE_MAX_ENTRIES < 1
djust.C303infoDJUST_VDOM_CACHE_ENABLED = False

Entries are keyed by URL only, within one browser profile; Vary headers are not honored. If the signed-in user or the language changes in the same browser, a cached snapshot from before the change can be shown until the live mount corrects it.

LiveView state snapshots

For a view that opts in, the server signs a snapshot of the view's public state when the view mounts, and the client replays it on back-navigation: the server then calls _restore_snapshot(state) instead of mount(). State changed by events after mount is not included, and scroll position is not view state, so neither is restored.

class CartView(LiveView):
    enable_state_snapshot = True

    def mount(self, request, **kwargs):
        self.items = list(request.user.cart_items.values('id', 'qty'))

    def _restore_snapshot(self, state: dict) -> None:
        # state is the signed snapshot taken when the view mounted.
        # Trust nothing — re-validate any IDs against the database.
        self.items = state.get('items', [])

    def _should_restore_snapshot(self, request) -> bool:
        # Override to reject snapshots. The default returns True; snapshot
        # age is enforced separately by DJUST_STATE_SNAPSHOT_MAX_AGE
        # (default 3600 s). Return False to fall back to mount().
        return super()._should_restore_snapshot(request)

Snapshots are JSON only (no pickle), capped at 256 KB by the SW and 64 KB by the client clamp, and safe_setattr blocks dunder / private attributes during restoration.

Snapshots are HMAC-signed by the server (Django TimestampSigner, keyed on SECRET_KEY) before they cross the wire. The client stores the opaque signed blob and echoes it back verbatim; on restore the server verifies the signature, a TTL (DJUST_STATE_SNAPSHOT_MAX_AGE, default 3600 s), and that the snapshot was issued for the same view and session. An unsigned, forged, tampered, expired, or cross-view/cross-session snapshot is rejected and the view falls back to mount(). This means a client cannot fabricate a snapshot to inject arbitrary public state — but you should still re-validate any IDs in _restore_snapshot against the database, since the signed state is whatever the server itself last captured. If you override _capture_snapshot_state / _restore_snapshot or ship a custom client, the signed blob must round-trip untouched.

System check djust.C304 warns if a snapshot-opt-in view declares attributes whose names match PII patterns (password, token, secret, api_key, pii) — a guardrail against accidentally shipping sensitive state through the SW.

Mount batching

Pages that render multiple dj-lazy LiveViews used to fire one mount WebSocket frame per view. As of v0.6.0 the client coalesces those into a single mount_batch frame; the server replies with one mount_batch carrying every rendered view, and per-view failures are isolated in a failed[] array — one bad view no longer kills the batch.

No code changes are required to opt in — the batching is automatic when the client and server are both ≥ v0.6.0. To opt out (e.g. for debugging), set:

window.DJUST_USE_MOUNT_BATCH = false;

before client.js runs.


Caveats & best practices

  • Shell staleness. The cached shell is tagged djust-shell-v1. When you deploy a template that changes <head> or <nav>, users will see the old shell until it is refreshed. Clear it by posting {type: 'DJUST_CLEAR_SHELL'} to the SW, or bumping the cache name in service-worker.js. A future version will wire this into djust's deploy signals.
  • Server actions must be idempotent over replay. The reconnection bridge replays buffered events best-effort. If a buffered event triggers a server-side side effect (payment, email send), the server currently has no dedup logic (v0.5.0 risk — see Out of scope). Use @event_handler for reads and low-stakes writes; guard high-stakes writes with your own idempotency keys.
  • Don't register the SW on authentication/session URLs. Scope the registration to /app/ if you have a login flow that must not be cached.
  • Dev mode. The SW caches the shell on the first successful navigate. During template development this can cache a broken shell. Either skip instantShell: true in dev, or bump SHELL_CACHE to invalidate.

Limitations

The shell/main extractor is a regex (/<main\b[^>]*>([\s\S]*?)<\/main>/i), not a full HTML parser. Known edge cases where it misbehaves:

  • Nested <main> tags (extremely rare, invalid HTML): only the first is matched.
  • <main> inside an HTML comment (<!-- <main>…</main> -->): the regex still matches the contents of the comment. Avoid literal <main> inside comments in templates.
  • </main> token inside a CDATA block within <main>: prematurely closes the match. Avoid inline <![CDATA[ … </main> … ]]> — this pattern is valid only inside <svg> / <math> and is very rare.

A full HTML-parser-based replacement is deferred. For most apps, the regex is correct 100% of the time.


Out of scope for v0.5.0

  • IndexedDB persistence of the reconnection buffer (survives browser restart). In-memory only today.
  • Server-side replay dedup via sequence numbers. The SW replays best-effort; write handlers that can tolerate duplicates, or add your own idempotency.
  • Full offline mode (serve any cached page when the server is unreachable).
  • PWA manifest / install prompt — use the existing PWA guide for that.
  • Push notifications / Background Sync.

Unregistering the service worker

<script>
    navigator.serviceWorker.getRegistrations().then(function (regs) {
        regs.forEach(function (r) { r.unregister(); });
    });
    caches.keys().then(function (keys) {
        keys.forEach(function (k) {
            if (k.indexOf('djust-shell') === 0) caches.delete(k);
        });
    });
</script>

  • PWA guide — offline caching manifest etc.
  • Reconnection — the core WS reconnect behaviour (separate from this bridge).