On this page
Streaming Initial Render¶
Phase 1 (v0.6.1) — transport-layer chunked transfer, regex-split-after-render. Phase 2 PR-A (v0.9.0) — async render path; the shell chunk now flushes to the wire BEFORE
get_context_data()runs (real TTFB win on ASGI). Phase 2 PR-B (v0.9.0) —{% live_render lazy=True %}opt-in lazy children. Phase 2 PR-C (v0.9.0) —asyncio.as_completed()parallel render across lazy children. v0.9.1 (#1145) —{% live_render lazy=True %}is now supported on the Rust template engine path. Production users onRustLiveViewwho opted into the faster Rust-rendered template engine can now use lazy children without falling back to the Django template engine. Behaviour is byte-for-byte identical between the two paths — the Rust handler delegates to the same Python implementation.
Honest Phase-1 vs Phase-2 caveat (closes retro #116)¶
The original v0.6.1 release notes called this feature "streaming initial
render" but the shipped path was actually a regex-split applied to the
already-fully-rendered HTML string. The chunks landed on the wire
after the entire view had completed get_context_data() and template
render. Time-to-first-byte was unchanged from HttpResponse. Phase 2 PR-A
(v0.9.0, ADR-015) delivers the actual shell-flush-before-render
semantic via async def aget() + python/djust/http_streaming.py's
ChunkEmitter. Phase 1 is retained as the WSGI-deployment fallback —
WSGI cannot push real chunks before the response is assembled, so on
WSGI the user gets the cosmetic 3-chunk split with no TTFB win.
djust can return a LiveView page as an HTTP/1.1 chunked-transfer
response instead of a single buffered response. On ASGI deployments
running v0.9.0 or later, the browser receives the shell chunk
(<!DOCTYPE> + <head> + <body> open) as soon as the parent view's
template is parsed, not when it's fully rendered. Intermediate proxies
that honor chunked encoding relay each chunk as it arrives.
Deployment requirements¶
- ASGI (Daphne, Uvicorn, Hypercorn) — full Phase-2 PR-A streaming.
The shell chunk reaches the wire while body chunks are still being
prepared by
arender_chunks()on the same event loop. - WSGI — falls back to Phase-1 cosmetic chunked response. The chunks
are correct but TTFB is unchanged from non-streaming.
aget()detects the missing event loop via_is_asgi_context()and routes toget()viasync_to_async. - Reverse proxies — nginx default
proxy_buffering oneats the TTFB win by buffering the entire response before relaying. To preserve Phase-2 streaming end-to-end: - nginx:
proxy_buffering offin the location block, OR setX-Accel-Buffering: noon the response (djust does not set this by default — add it via middleware if needed). - Cloudflare: chunked transfer is supported; no extra config.
- AWS ALB / GCP LB: chunked transfer supported.
Foundation for lazy children (PR-B preview)¶
Phase 2 PR-B introduces {% live_render "..." lazy=True %} opt-in. The
tag emits a <dj-lazy-slot> placeholder synchronously and registers a
render thunk on parent._chunk_emitter. After the parent shell
flushes, the emitter runs each thunk and emits a
<template id="djl-fill-X"> chunk + inline <script> that the browser
parses and the client uses to replaceWith the slot.
PR-A ships ONLY the foundation (aget(), ChunkEmitter,
arender_chunks()). PR-B ships the user-facing lazy=True API. PR-C
adds parallelization via asyncio.as_completed(). This split-foundation
shape follows retro #1122.
Original Phase-1 caveat (kept for archival reference): Phase 1 doesn't do true server-side overlap — rendering the main content while the browser is parsing the shell. Phase 2 PR-A introduces that capability.
This is the djust analog of Next.js
renderToPipeableStream:
opting in flips the HTTP response type from HttpResponse to
StreamingHttpResponse with no other API changes. The full Next.js
experience (shell-first paint during component render) arrives with
Phase 2.
Quick start¶
from djust import LiveView
class DashboardView(LiveView):
template_name = "dashboard.html"
streaming_render = True # ← opt in
def mount(self, request, **kwargs):
# Slow work here delays Chunk 2, but Chunk 1 has already
# arrived at the browser — CSS is loading, fonts are warming.
self.rows = fetch_expensive_rows()
That's it. No JS changes, no new template tags, no new URL routing —
the existing path("/dashboard/", DashboardView.as_view()) just works.
PR-A foundation status (v0.9.0, in flight): the async-streaming render path (
aget(),ChunkEmitter,arender_chunks()) lands as PR-A. Dispatch wiring that auto-routes GET →aget()whenstreaming_render = Truelands together with PR-B ({% live_render lazy=True %}), because the user-visible TTFB win arrives at the same time as the user-facing API. Until PR-B merges, settingstreaming_render = Truecontinues to take the Phase-1 regex-split-after-render path documented at the top — the ASGI shell- flush behavior described below activates with PR-B.
How it works¶
When streaming_render = True, LiveView.get() splits the rendered
HTML into three chunks at well-defined boundaries and yields each chunk
to the wire as soon as it's ready:
| Chunk | Contents | Browser behavior |
|---|---|---|
| 1. Shell-open | Everything before <div dj-root> — <!DOCTYPE html>, <head>, <link rel="stylesheet">, <body> open, top chrome |
Starts parsing <head>, fires CSS + JS downloads, paints page background |
| 2. Main content | The <div dj-root>...</div> block — the entire LiveView body |
Inserts the view's DOM; djust client script runs on DOMContentLoaded |
| 3. Shell-close | </body></html> + trailing markup |
Finishes document parse |
Browsers begin DOM construction the moment Chunk 1 arrives, so linked
stylesheets and <script defer> tags are already in-flight while your
Python code is still computing the view state.
The response omits the Content-Length header (HTTP chunked transfer
is implicit) and sets X-Djust-Streaming: 1 as an observability marker
so you can verify the feature is active from your browser's Network
panel.
When to use it¶
Good fit:
- Pages where
mount()orget_context_data()make slow external calls (database aggregations, REST APIs, S3 lookups, LLM calls). - Dashboards with large query fan-out — each row-count query adds to time-to-first-byte under the non-streaming path.
- Public landing pages where
<link rel="stylesheet">in<head>determines Largest Contentful Paint — flushing the head early is a measurable LCP win.
Not worth it:
- Small, fast pages where the server renders in < 50 ms. The fixed overhead of chunked transfer (extra bytes per chunk, proxy buffering risk) can exceed the benefit on sub-frame renders.
- Pages served behind a reverse proxy that buffers responses by default (see caveats).
Caveats¶
- No
Content-Length. Some reverse proxies (notably default nginx +proxy_buffering on) buffer chunked responses into a single write, defeating the streaming benefit. Setproxy_buffering off;on the nginx location block, or switch the proxy to HTTP/2 (which handles streaming natively). - Middleware that inspects the response body must be
streaming-aware. Middleware reading
response.contenton aStreamingHttpResponseraisesAttributeError: ... content. If you have custom middleware, guard body reads withisinstance(response, StreamingHttpResponse). All of djust's built-in middleware is streaming-safe as of v0.6.1. - CSP nonces generated by
django-cspwork fine — nonces are produced during template render (before any chunk is sent) and theContent-Security-Policyresponse header is set once on theStreamingHttpResponse, not per-chunk. Lazy-child fills also honor the nonce: whenrequest.csp_nonceis set, both the<template id="djl-fill-X">element and the inline<script>activator emitted by{% live_render lazy=True %}carry a matchingnonce="..."attribute, so strict-CSP deployments (script-src 'nonce-...', no'unsafe-inline') accept the activator. Whenrequest.csp_nonceis absent (no CSP middleware installed, or the request hasn't been processed by it), nononceattribute is emitted — backward-compatible for non-CSP sites. See #1147. - HTML without a
<div dj-root>(edge case — raw body fragments) falls back to a single-chunk response equivalent toHttpResponse(html). Streaming is a no-op in that case. - Literal
</body>tokens inside<style>blocks or HTML comments. The chunk-splitter masks<script>...</script>content so a literal</body>inside a JavaScript string does not create a false split boundary, but it does not currently mask<style>or<!-- ... -->blocks. If your template inlines</body>as literal string content inside<style>or an HTML comment, the split may fire at the wrong position. In practice this is extremely rare — for almost all apps it is not a concern. If your template does this legitimately, verify the streamed chunks via your browser's Network panel.
Startup checks for {% live_render %} misuse¶
djust's system-check framework includes a startup-time guard for
{% live_render %} tags. Run via manage.py check (or any Django
process startup):
djust.A075(Warning) — fires when a template contains{% live_render "..." sticky=True lazy=True %}. The two kwargs are mutually exclusive: sticky preservation requires the slot to exist at mount-frame time so the WebSocket reattach canreplaceWiththe stashed subtree, whilelazy=Truedefers slot rendering until after the parent shell flushes — the stash target doesn't exist when reattach runs. The runtime tag-eval path already raisesTemplateSyntaxErroron collision, but A075 surfaces the misuse before any request hits.- The check skips
{% verbatim %}...{% endverbatim %}regions, so docs/marketing pages that show the anti-pattern as a literal example don't false-positive. -
To silence (e.g. for documentation projects that aren't actually using djust):
python DJUST_CONFIG = {"suppress_checks": ["A075"]}
Strict CSP support for lazy=True (#1147)¶
Sites that deploy strict Content-Security-Policy headers without
'unsafe-inline' (i.e. script-src 'nonce-<nonce>') need every
inline <script> element to carry a matching nonce attribute.
djust's lazy-fill activator integrates with the standard Django
convention (request.csp_nonce, set by django-csp middleware or
any compatible CSP package):
- When
request.csp_nonceis set — the framework emitsnonce="..."on the lazy-fill<template>element AND on the inline<script>activator that callswindow.djust.lazyFill(...). The browser's CSP enforcer accepts the activator at parse time without any client-side intervention. - When
request.csp_nonceis absent or empty — nononceattribute is emitted. Sites without CSP middleware see no change from previous behavior.
Implementation detail: the framework reads
getattr(request, 'csp_nonce', None) via the existing
djust.utils.get_csp_nonce helper. Any CSP middleware that follows
the same convention (the de-facto Django standard) is supported
out of the box — no additional configuration is required.
Comparison¶
| Feature | HttpResponse (default) |
streaming_render = True |
Next.js renderToPipeableStream |
|---|---|---|---|
| Response type | HttpResponse |
StreamingHttpResponse |
ReadableStream |
| Transfer encoding | Content-Length: N |
Transfer-Encoding: chunked |
Transfer-Encoding: chunked |
| Time-to-first-byte | After render complete | After shell-open ready (~ms) | After shell-open ready (~ms) |
| Chunks | 1 | 3 (shell / main / close) | N (per Suspense boundary) |
| Out-of-order render | No | No (Phase 1) | Yes (React Suspense) |
| Opt-in per view | n/a | streaming_render = True |
<Suspense> wrapping |
| Client-side code needed | None | None | React runtime |
Phase 1 matches the first-paint win of renderToPipeableStream
without the Suspense machinery. Phase 2 (planned for v0.6.2) adds
out-of-order rendering via lazy-child placeholders that stream in after
the main chunk.
Future work — Phase 2 (v0.6.2)¶
Lazy-child streaming extends this to {% live_render %} children
marked lazy=True:
- Parent yields a
<div dj-view dj-lazy>placeholder inside Chunk 2. - After Chunk 3, the parent continues streaming each lazy child as a
<template data-target="dj-lazy-N">...</template>+ inline<script>djust.streamFill('dj-lazy-N')</script>sequence. - A new client module adopts the template content into the target container — equivalent to React Suspense's streaming resolution.
This lets you ship an instant shell with placeholder UI and stream in
heavy children (charts, tables, LLM output) as they become ready —
closer parity with renderToPipeableStream and React Server
Components. Tracked on the ROADMAP as v0.6.2 scope.