Architecture overview¶
This page is the most technical one in the docs. If you've read How the two fit together and you're comfortable with that mental model, you can skip this until you actually need to look something up.
What follows is the full runtime picture: the ASGI dispatcher, the event bridge, the bootstrap order, how state survives across events, and how the dev server's rebuild loop works.
The three pillars¶
reflex-django is built from three independent pieces. Each does one job:
flowchart LR
subgraph pillar1["1. Bootstrap"]
A["urls.py imports reflex_mount"] --> B["install_reflex_django_integration"]
B --> C["configure_django, patch get_config"]
C --> D["django_led_app imports {app}/views.py"]
end
subgraph pillar2["2. ASGI dispatch"]
E["ASGI server :8000"] --> F["DjangoOuterDispatcher"]
F -->|reserved Reflex paths| G["Reflex inner _api"]
F -->|lifespan| H["Reflex lifespan tasks"]
F -->|everything else| I["Django ASGI handler"]
end
subgraph pillar3["3. Event bridge"]
J["WebSocket /_event"] --> K["DjangoEventBridge"]
K --> L["settings.MIDDLEWARE per event"]
L --> M["self.request / self.user / self.response"]
end
| Pillar | Job |
|---|---|
| Bootstrap | At import time, register Reflex config from reflex_mount(), set up Django, discover pages, build rx.App. |
| ASGI dispatch | At request time, an outer ASGI app routes each scope to Django or to Reflex's inner ASGI. |
| Event bridge | At event time, build a synthetic HttpRequest, run middleware, and bind context onto the handler. |
Runtime topology¶
flowchart TB
subgraph Client["Browser on port 8000"]
BrowserHTTP["HTTP requests<br/>/, /admin, /api, /static, /_reflex"]
BrowserWS["WebSockets<br/>/_event Socket.IO"]
end
subgraph Process["Single ASGI process - asgi_entry:application"]
Outer["DjangoOuterDispatcher"]
subgraph DjangoLayer["Django"]
DjangoMW["settings.MIDDLEWARE chain"]
DjangoViews["urls.py views"]
Mount["ReflexMountView<br/>SPA catch-all"]
DjangoORM[("ORM / DB")]
end
subgraph ReflexLayer["Reflex mounted under Django"]
ReflexAPI["rx_app._api<br/>Starlette + Socket.IO"]
EventBridge["DjangoEventBridge"]
Handlers["rx.event handlers<br/>AppState subclasses"]
end
SPA["STATIC_ROOT/_reflex<br/>compiled SPA bundle"]
end
BrowserHTTP --> Outer
BrowserWS --> Outer
Outer -->|reserved Reflex paths| ReflexAPI
Outer -->|lifespan| ReflexAPI
Outer -->|everything else| DjangoMW
DjangoMW --> DjangoViews
DjangoMW --> Mount
Mount --> SPA
ReflexAPI --> EventBridge
EventBridge --> Handlers
Handlers --> DjangoORM
DjangoViews --> DjangoORM
One Python process owns the database connection pool, the in-memory state manager, and the compiled Reflex bundle. There's no second process, no second port, no CORS, no token bridge.
The outer dispatcher¶
reflex_django.django_outer_dispatcher.DjangoOuterDispatcher is the ASGI callable returned by reflex_django.asgi_entry.application. Every incoming ASGI scope passes through it first.
It owns four routing decisions:
incoming ASGI scope
│
▼
scope["type"] == "lifespan" ──► Reflex lifespan (event processor, prerender, background tasks)
scope["type"] == "websocket" ──► reserved Reflex path?
├── yes → Reflex inner _api
└── no → close gracefully (no Channels needed)
scope["type"] == "http" ──► reserved Reflex path?
├── yes → Reflex inner _api
└── no → Django ASGI handler
Reserved Reflex prefixes¶
These paths are always sent to Reflex's inner ASGI, regardless of urls.py:
| Prefix | Purpose |
|---|---|
/_event |
Socket.IO state-update channel |
/_upload |
Reflex file upload endpoint |
/_health, /ping |
Liveness probes |
/_all_routes |
Internal route enumeration |
/auth-codespace |
Reflex auth dev tooling |
Customize via REFLEX_DJANGO_RESERVED_REFLEX_PREFIXES.
The SPA catch-all¶
Anything that isn't a reserved prefix and isn't a django_prefix falls through Django's urls.py to ReflexMountView. That view:
- Resolves the compiled SPA index (
STATIC_ROOT/_reflex/index.html,.web/build/client/index.html, or.web/_static/index.html). - Optionally pipes the HTML through Django's template engine (
REFLEX_DJANGO_RENDER_SPA_VIA_TEMPLATE_ENGINE = True) so the shell can render{{ request.user }},{% csrf_token %},{{ messages }},{% load i18n %}. - Streams JS/CSS/images untouched.
If the bundle is missing, the view returns a 404 with a hint pointing at manage.py export_reflex.
HTTP request lifecycle¶
Browser request → ASGI server → DjangoOuterDispatcher
│
├── /_event, /_upload, /_health, …
│ └─► Reflex _api (full Reflex pipeline)
│
└── everything else → Django ASGI handler
│
▼
settings.MIDDLEWARE (full chain)
│
├── /admin/ → admin views
├── /api/ → your DRF views
├── /static/ → ASGIStaticFilesHandler
└── /<anything> → urls.py → ReflexMountView
│
▼
STATIC_ROOT/_reflex/index.html
(optionally Django-templated)
Django middleware sees every page navigation — same process_request, process_view, process_response, process_exception semantics as for /admin and /api. The Reflex SPA shell is just another Django response.
WebSocket event lifecycle¶
Reflex state mutations travel over Socket.IO on /_event. The dispatcher hands those scopes straight to Reflex's inner ASGI, but before the handler runs, the DjangoEventBridge wraps the event with a full Django request/response context.
sequenceDiagram
autonumber
participant Browser
participant Reflex as Reflex engine
participant Bridge as DjangoEventBridge
participant Handler as EventMiddlewareHandler
participant State as @rx.event handler
Browser->>Reflex: socket event (router_data + payload)
Reflex->>Bridge: preprocess(event)
Bridge->>Bridge: build synthetic HttpRequest
Bridge->>Handler: dispatch(request) through settings.MIDDLEWARE
Handler-->>Bridge: HttpResponse + populated request
Bridge->>Bridge: eager-resolve request.user (aget_user)
Bridge->>Bridge: bind contextvars (request, response,<br/>messages, csrf_token, language)
alt response is 3xx
Bridge-->>Reflex: rx.redirect(Location) (skip handler)
else response is 2xx/4xx/5xx
Bridge-->>Reflex: proceed to handler
end
Reflex->>State: invoke handler
State->>State: read self.request, self.user, self.response,<br/>self.messages, self.csrf_token, …
State-->>Reflex: state mutations
Reflex-->>Browser: reactive UI updates
What gets bound to the handler¶
Inside any @rx.event method on an AppState subclass:
| Attribute | Value |
|---|---|
self.request |
Synthetic HttpRequest after the middleware chain |
self.response |
HttpResponse after the chain (200 unless a middleware short-circuited) |
self.user |
request.user (already resolved — no SynchronousOnlyOperation) |
self.session |
request.session (async-safe access) |
self.messages |
[{level, level_tag, message, tags, extra_tags}, …] snapshot |
self.csrf_token |
CSRF token for the current request |
self.django_response |
Raw HttpResponse (handy for inspecting headers) |
self.resolver_match |
ResolverMatch if the path resolves to a Django view |
self.django_context |
Dict of context-processor keys (when REFLEX_DJANGO_AUTO_LOAD_CONTEXT = True) |
Middleware short-circuits become navigations¶
If any middleware returns a response without calling the next layer — for example LoginRequiredMiddleware returning HttpResponseRedirect("/login") — the bridge converts that 3xx into a Reflex rx.redirect(...) event. The browser navigates; the handler does not run.
Disable with REFLEX_DJANGO_AUTO_REDIRECT_FROM_MIDDLEWARE = False.
Skipped middleware¶
CsrfViewMiddleware and reflex_django.streaming_middleware.AsyncStreamingMiddleware are always skipped on Socket.IO events. CSRF doesn't apply to same-origin WebSocket traffic, and streaming responses don't exist there. Override with REFLEX_DJANGO_EVENT_MIDDLEWARE_SKIP.
Reactive bridge: Django context inside the UI¶
The middleware chain populates the handler's context. AppState also exposes the reactive counterparts the SPA can bind to directly:
class HomeState(AppState):
pass # AppState already exposes the reactive fields
def navbar():
return rx.hstack(
rx.cond(
DjangoUserState.is_authenticated,
rx.text(f"Hi, {DjangoUserState.username}"),
rx.link("Sign in", href="/login"),
),
rx.spacer(),
rx.text(f"Locale: {DjangoUserState.language}"),
)
def message_banner():
return rx.foreach(
DjangoUserState.messages,
lambda m: rx.callout(m.message, color_scheme=m.level_tag),
)
def hidden_csrf():
return rx.el.input(
type="hidden", name="csrfmiddlewaretoken", value=DjangoUserState.csrf_token
)
Reactive var on DjangoUserState |
Source |
|---|---|
is_authenticated, username, email, is_staff, is_superuser |
request.user |
messages |
django.contrib.messages.get_messages(request) snapshot |
csrf_token |
django.middleware.csrf.get_token(request) |
language, language_bidi |
translation.get_language() / get_language_bidi() |
perms |
JSON-safe request.user.get_all_permissions() |
Toggle individual mirrors with REFLEX_DJANGO_MIRROR_MESSAGES, REFLEX_DJANGO_MIRROR_CSRF, REFLEX_DJANGO_MIRROR_LANGUAGE.
State serialization¶
Reflex periodically pickles BaseState instances to its state manager (memory, Redis, etc.). Django's HttpRequest and ResolverMatch are not picklable, so reflex-django patches BaseState.__getstate__ to strip the transient _django_led_request_wrapper and _django_led_response attributes before serialization. The next event rebuilds them from the incoming router_data.
In other words: you never lose self.request between events, but you also don't pay to ship it across processes.
Frontend bundle: built once, served from disk¶
The Reflex SPA is always served from a compiled bundle on disk. There's no separate frontend dev server unless you explicitly opt in with --with-vite. The bundle lives at:
STATIC_ROOT/_reflex/ # canonical location, served by ReflexMountView
.web/build/client/ # build output (SSR layout)
.web/_static/ # build output (legacy layout)
manage.py run_reflex rebuilds that bundle in-process before starting the ASGI server, then watches the project root for .py changes. Each change triggers a clean rebuild and uvicorn restart:
flowchart LR
A["manage.py run_reflex"] --> B["export_reflex<br/>(frontend-only, no-zip, staged)"]
B --> C["uvicorn subprocess<br/>:8000"]
C --> D["watchfiles loop<br/>(BASE_DIR/*.py)"]
D -->|file change| E["stop uvicorn"]
E --> F["re-export"]
F --> G["start uvicorn"]
G --> D
The rebuild happens in a parent watcher and the server is a clean subprocess. Every restart serves the freshly compiled bundle — no stale assets, no half-reloaded modules.
Environment profiles¶
| Aspect | Development | Production |
|---|---|---|
| Processes | One: ASGI server (uvicorn) | One: ASGI server (uvicorn / granian / hypercorn) |
| Frontend bundle | Auto-rebuilt by run_reflex on every .py change |
Built in CI, copied into STATIC_ROOT/_reflex |
| Reload | Parent-side watchfiles drives clean uvicorn restarts |
None — the container or systemd unit owns lifecycle |
| Static files | Served by Django's ASGIStaticFilesHandler |
Served by Nginx/Caddy from STATIC_ROOT |
| DEBUG | True |
False |
REFLEX_DJANGO_DEV_PROXY |
Off (no upstream needed) | Off |
| "Built with Reflex" badge | Off by default | Off by default |
Bootstrap order¶
The order in which modules import each other is load-bearing. From the top of any entry point:
1. DJANGO_SETTINGS_MODULE is set (manage.py or asgi.py)
2. reflex_django.asgi_entry is imported
└── install_reflex_django_integration()
├── patches reflex.config.get_config to read reflex_mount() output
├── configure_django() — django.setup()
├── refresh_get_config_bindings() — re-resolves cached config references
└── ensure_reflex_cli_layout() — synthesises rxconfig in sys.modules
3. ROOT_URLCONF is imported → reflex_mount() registers the in-memory rx.Config
4. reflex_django.django_led_app imports {app}/views.py for each INSTALLED_APPS entry
5. rx.App() is instantiated; @template / @page decorators register routes
6. DjangoOuterDispatcher wraps Django ASGI + Reflex inner ASGI
7. ASGI server binds the port
Once that completes, the process is ready to serve. Every subsequent request flows through the dispatcher described above.
Why a single process¶
Everything that follows is a consequence of running both frameworks in one process:
- Shared sessions out of the box. Logging in via
/admin/and readingrequest.userfrom a Reflex event use the sameSessionMiddleware, the same session store, and the same database connection. - No cross-origin handshake. The SPA, the API, and the WebSocket all share an origin. Cookies just work.
- One deploy unit. One container, one systemd unit, one log stream, one set of env vars.
- Database connection reuse. Django ORM connections live in the same process as Reflex event handlers, so a handler can call
await Model.objects.aget(...)without crossing a process boundary.