Real-Time Crypto Trading Dashboard: Litestar, Django, Redis, and Postgres

Real-Time Crypto Trading Dashboard: Litestar, Django, Redis, and Postgres

A trading dashboard has two personalities that pull in opposite directions.

One is live: prices and order-book depth that must reach the browser in milliseconds over a persistent connection, for thousands of concurrent clients. The other is bookkeeping: assets, users, portfolios, historical candles, and an admin panel — slow-moving, relational, transactional, and boring on purpose.

The mistake is to build both in one framework. Django is superb at the second and awkward at the first. An async framework is superb at the first and would make you hand-roll an admin panel, auth, and migrations for the second. This post wires them together with Redis as the seam and PostgreSQL/TimescaleDB as the source of truth.

It’s the stack I’d actually reach for:

  • Litestar is for an async WebSocket endpoints streaming live prices and order-book updates.
  • Django is for an admin for managing assets, users, and portfolios; ORM for historical data.
  • Redis is for a pub/sub for price fan-out, caching hot tickers, rate limiting API calls.
  • Postgres (+ TimescaleDB) is for the time-series price history and user portfolios.
  • Celery is working with workers, which is pulling from exchange APIs and storing into Postgres.

The One Diagram

Draw this first. Everything else is filling in arrows.

                       ┌──────────────────────────────────────────┐
                       │              Browser / SPA                │
                       │   REST (portfolios)   +   WS (live feed)  │
                       └───────┬───────────────────────┬──────────┘
                               │ HTTPS                 │ WSS
                 ┌─────────────▼──────────┐   ┌─────────▼──────────────────┐
                 │        Django          │   │         Litestar           │
                 │  admin, auth, ORM      │   │  async WS hub, REST reads  │
                 │  assets/users/portfolio│   │  /ws/tickers/{symbol}      │
                 └───────┬────────────────┘   └─────────┬──────────────────┘
                         │ ORM                          │ SUBSCRIBE / GET
                         │                              │
                 ┌───────▼──────────────────────────────▼──────────────────┐
                 │                        Redis                             │
                 │  pub/sub: prices.<symbol>   cache: ticker:<symbol>       │
                 │  rate limits, Celery broker/backend                      │
                 └───────┬──────────────────────────────▲──────────────────┘
                         │ PUBLISH / SETEX              │ SUBSCRIBE
                 ┌───────▼─────────────┐        ┌───────┴──────────────┐
                 │   Celery workers    │        │   Celery beat        │
                 │   poll exchange API │        │   schedule polling   │
                 └───────┬─────────────┘        └──────────────────────┘
                         │ batch INSERT
                 ┌───────▼─────────────────────────────────────────────────┐
                 │              PostgreSQL  (+ TimescaleDB)                 │
                 │  price_history hypertable, candles_1m continuous agg     │
                 │  assets, portfolios, positions  (relational)             │
                 └──────────────────────────────────────────────────────────┘

The shape to notice: writes and reads take different roads. Live tick data enters through Celery → Redis → Litestar and never touches Django. Relational data is owned by Django’s ORM. Both share the same Postgres, but only one writer per table.

Litestar: The Async Edge

Litestar is an ASGI framework built for exactly this: typed handlers, dependency injection, and WebSockets that aren’t an afterthought. The real-time endpoint is the reason it’s here.

The pattern is a small in-process hub of rooms (one room per symbol) plus a background task that subscribes to Redis and re-broadcasts to local sockets.

# realtime/hub.py
from __future__ import annotations

import asyncio

import orjson
from litestar.connection import WebSocket


class Hub:
    """In-process registry of websockets, grouped into rooms."""

    def __init__(self) -> None:
        self._rooms: dict[str, set[WebSocket]] = {}
        self._lock = asyncio.Lock()

    async def join(self, room: str, ws: WebSocket) -> None:
        async with self._lock:
            self._rooms.setdefault(room, set()).add(ws)

    async def leave(self, room: str, ws: WebSocket) -> None:
        async with self._lock:
            self._rooms.get(room, set()).discard(ws)

    async def broadcast(self, room: str, message: dict) -> None:
        payload = orjson.dumps(message)
        # Snapshot the set: a send failure mutates _rooms via leave().
        for ws in list(self._rooms.get(room, ())):
            try:
                await ws.send_bytes(payload)
            except Exception:
                await self.leave(room, ws)


hub = Hub()

The WebSocket handler joins a room, sends an immediate cached snapshot (so a freshly-connected client isn’t blind until the next tick), and then mostly idles — the push comes from Redis, not from a loop in the handler.

# realtime/endpoints.py
import orjson
from litestar import websocket
from litestar.connection import WebSocket
from litestar.exceptions import WebSocketDisconnect

from .cache import get_cached_ticker
from .hub import hub


@websocket("/ws/tickers/{symbol:str}")
async def ticker_stream(socket: WebSocket, symbol: str) -> None:
    await socket.accept()
    room = f"ticker:{symbol.upper()}"
    await hub.join(room, socket)
    try:
        # 1) Snapshot from Redis so the UI paints immediately.
        if snapshot := await get_cached_ticker(symbol):
            await socket.send_bytes(orjson.dumps({"type": "ticker", "data": snapshot}))

        # 2) Keep the connection open; client messages are pings/subscriptions.
        async for _ in socket.iter_json():
            pass
    except WebSocketDisconnect:
        pass
    finally:
        await hub.leave(room, socket)

The bridge from Redis to the hub is a single lifespan-managed task. One subscriber per Litestar process, not one per client — that’s the whole point of pub/sub fan-out.

# realtime/pubsub.py
import asyncio

import orjson
import redis.asyncio as redis

from .hub import hub


async def redis_fanout() -> None:
    client = redis.from_url("redis://localhost:6379")
    pubsub = client.pubsub()
    await pubsub.psubscribe("prices.*")

    async for msg in pubsub.listen():
        if msg["type"] != "pmessage":
            continue  # subscription confirmations etc.
        channel = msg["channel"].decode()          # prices.BTCUSDT
        symbol = channel.split(".", 1)[1]
        data = orjson.loads(msg["data"])
        await hub.broadcast(f"ticker:{symbol}", {"type": "ticker", "data": data})
# realtime/app.py
import asyncio
from contextlib import asynccontextmanager

from litestar import Litestar
from litestar.config.cors import CORSConfig

from .endpoints import ticker_stream
from .pubsub import redis_fanout


@asynccontextmanager
async def lifespan(app: Litestar):
    task = asyncio.create_task(redis_fanout())
    try:
        yield
    finally:
        task.cancel()


app = Litestar(
    route_handlers=[ticker_stream],
    lifespan=[lifespan],
    cors_config=CORSConfig(allow_origins=["https://dashboard.example.com"]),
)

Run it with uvicorn realtime.app:app --workers 1. Workers matter: the hub is in-process, so with --workers 4 you get four independent hubs. That’s fine — each subscribes to Redis and pushes only to its own sockets — but it means you must never assume a client is “connected to the hub”; the hub is a cache of sockets, Redis is the bus.

If you’d rather not run a separate process, Litestar can mount Django’s ASGI app for the admin/REST routes and keep the WebSocket route native. Separate processes are simpler to operate and scale independently, which is what I’d do in production.

Django: The Admin and the System of Record

Django earns its place for one brutally practical reason: you get a working admin panel for assets, users, and portfolios on day one. Writing that in an async framework is a week you’ll never get back.

# core/models.py
from django.db import models


class Asset(models.Model):
    symbol = models.CharField(max_length=20, unique=True)      # BTCUSDT
    name = models.CharField(max_length=100)
    exchange = models.CharField(max_length=40, default="binance")
    is_active = models.BooleanField(default=True)

    def __str__(self) -> str:
        return self.symbol


class Portfolio(models.Model):
    user = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="portfolios")
    name = models.CharField(max_length=80)
    created_at = models.DateTimeField(auto_now_add=True)


class Position(models.Model):
    portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name="positions")
    asset = models.ForeignKey(Asset, on_delete=models.PROTECT)
    # Money is NUMERIC, never float. 8 dp matches typical crypto precision.
    quantity = models.DecimalField(max_digits=24, decimal_places=8)
    avg_cost = models.DecimalField(max_digits=24, decimal_places=8)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["portfolio", "asset"], name="uniq_position"),
        ]
# core/admin.py
from django.contrib import admin

from .models import Asset, Portfolio, Position


@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
    list_display = ("symbol", "name", "exchange", "is_active")
    list_filter = ("exchange", "is_active")
    search_fields = ("symbol", "name")


class PositionInline(admin.TabularInline):
    model = Position
    extra = 0


@admin.register(Portfolio)
class PortfolioAdmin(admin.ModelAdmin):
    list_display = ("name", "user", "created_at")
    inlines = [PositionInline]


admin.site.register(Position)

The valuation read is a join between the relational tables and the live-ish price. If you’re on TimescaleDB, read the latest candle; otherwise read the cached ticker from Redis for a fast display value and hit Postgres for the authoritative close.

# core/services.py
from decimal import Decimal

from django.db import connection


def portfolio_value(portfolio_id: int) -> Decimal:
    """Current value using the most recent close per held asset."""
    with connection.cursor() as cur:
        cur.execute(
            """
            WITH latest AS (
                SELECT DISTINCT ON (symbol) symbol, close
                FROM price_history
                ORDER BY symbol, time DESC
            )
            SELECT COALESCE(SUM(p.quantity * latest.close), 0)
            FROM core_position p
            JOIN core_asset a ON a.id = p.asset_id
            JOIN latest ON latest.symbol = a.symbol
            WHERE p.portfolio_id = %s
            """,
            [portfolio_id],
        )
        return Decimal(cur.fetchone()[0])

The hard rule: Django owns writes to assets, portfolios, and positions. Litestar reads hot data and never writes relational tables. Two ORMs writing the same schema is how you get subtle corruption and duplicated migration history. One writer per table, always.

Redis: The Seam Between Write and Read

Redis does three distinct jobs here, and it’s worth keeping them mentally separate because they have different durability needs.

1. Pub/sub for price fan-out

Celery publishes one message per tick; every Litestar process receives it and pushes to its own sockets. Publishers never know how many clients exist.

# publisher side (Celery)
import orjson
import redis

rdb = redis.Redis.from_url("redis://localhost:6379")
rdb.publish("prices.BTCUSDT", orjson.dumps(tick))

Honest caveat: classic Redis pub/sub is fire-and-forget. If nothing is subscribed, the message is gone. That’s correct for live ticks (a missed tick is stale within a second) and wrong for anything you need to replay. If you need replay or consumer groups, use Redis Streams (XADD/XREADGROUP) or Kafka instead. Don’t bolt “durability” onto pub/sub with wishful thinking.

2. Caching hot tickers

The snapshot a new WebSocket client receives, and the value the dashboard shows before the first tick arrives:

# realtime/cache.py
import orjson
import redis.asyncio as redis

_redis = redis.from_url("redis://localhost:6379")


async def get_cached_ticker(symbol: str) -> dict | None:
    raw = await _redis.get(f"ticker:{symbol.upper()}")
    return orjson.loads(raw) if raw else None

Writers set it with a short TTL so a dead worker’s last price ages out instead of lying forever:

rdb.setex(f"ticker:{tick['symbol']}", 30, orjson.dumps(tick))

3. Rate limiting exchange calls

Exchange APIs are weight-limited (Binance counts request weight against a rolling window). A per-exchange token bucket in Redis keeps every Celery worker honest without a central lock.

-- scripts/rate_limit.lua  — fixed-window limiter, atomic
-- KEYS[1] = limiter key, ARGV[1] = max, ARGV[2] = window seconds
local key    = KEYS[1]
local limit  = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call("INCR", key)
if current == 1 then
  redis.call("EXPIRE", key, window)
end
if current > limit then
  return 0
end
return 1
# allowed = 1, denied = 0
allowed = rdb.eval(RATE_LIMIT_LUA, 1, "rl:binance", 1200, 60)
if not allowed:
    raise self.retry(countdown=1)   # or sleep until the window rolls

For production, prefer a real token-bucket (Lua, or the redis-cell module) over a fixed window — fixed windows allow a 2x burst at the boundary.

PostgreSQL / TimescaleDB: Time-Series and Portfolios

One database, two workloads. Keep them honest with the right object for each.

Price history as a hypertable

Ticks and candles are append-only, time-ordered, and queried by range. That is exactly what TimescaleDB is for. On plain Postgres, use declarative partitioning by RANGE (time) with the same mental model.

CREATE TABLE price_history (
    time   TIMESTAMPTZ NOT NULL,
    symbol TEXT        NOT NULL,
    open   NUMERIC,
    high   NUMERIC,
    low    NUMERIC,
    close  NUMERIC,
    volume NUMERIC
);

-- Partition into ~1 day chunks; queries by time touch few chunks.
SELECT create_hypertable('price_history', 'time', chunk_time_interval => INTERVAL '1 day');

-- The query pattern is always (symbol, time range): index accordingly.
CREATE INDEX ON price_history (symbol, time DESC);

Continuous aggregates for candles

The dashboard never asks for raw ticks; it asks for 1-minute candles. Compute them once, incrementally, instead of on every request.

CREATE MATERIALIZED VIEW candles_1m
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 minute', time) AS bucket,
    symbol,
    first(open, time)  AS open,
    max(high)          AS high,
    min(low)           AS low,
    last(close, time)  AS close,
    sum(volume)        AS volume
FROM price_history
GROUP BY bucket, symbol;

SELECT add_continuous_aggregate_policy('candles_1m',
    start_offset      => INTERVAL '2 hours',
    end_offset        => INTERVAL '1 minute',
    schedule_interval => INTERVAL '1 minute');

Retention

Raw ticks are heavy and old ones stop mattering. Drop them automatically; keep the aggregates.

SELECT add_retention_policy('price_history', INTERVAL '90 days');

The relational tables (assets, portfolios, positions) live in the same database, owned by Django. The time-series tables live beside them, written by a single batched writer (below) and read by both frameworks.

Why one database and not two? A join between positions and price_history (the portfolio valuation above) is trivial in one Postgres and a distributed-query problem across two. Don’t split until you’ve measured.

Celery: Pulling the Real World In

Celery beat schedules polling; workers fetch, normalize, persist, cache, and publish.

# tasks.py
from datetime import datetime, timezone

import httpx
import orjson
import redis as redis_sync
from celery import Celery

app = Celery(
    "trading",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)
rdb = redis_sync.Redis.from_url("redis://localhost:6379")


@app.task(
    bind=True,
    max_retries=5,
    autoretry_for=(httpx.HTTPError,),
    retry_backoff=True,          # exponential backoff + jitter
    retry_jitter=True,
)
def fetch_ticker(self, symbol: str) -> dict:
    resp = httpx.get(
        "https://api.binance.com/api/v3/ticker/24hr",
        params={"symbol": symbol},
        timeout=5.0,
    )
    resp.raise_for_status()
    raw = resp.json()

    tick = {
        "symbol": symbol,
        "price": raw["lastPrice"],
        "high": raw["highPrice"],
        "low": raw["lowPrice"],
        "volume": raw["volume"],
        "ts": datetime.now(timezone.utc).isoformat(),
    }

    payload = orjson.dumps(tick)
    rdb.setex(f"ticker:{symbol}", 30, payload)      # 1) cache hot value
    rdb.publish(f"prices.{symbol}", payload)        # 2) fan out to Litestar
    queue_for_storage(tick)                         # 3) hand off to writer
    return tick


app.conf.beat_schedule = {
    "poll-btc": {"task": "tasks.fetch_ticker", "schedule": 1.0, "args": ("BTCUSDT",)},
    "poll-eth": {"task": "tasks.fetch_ticker", "schedule": 1.0, "args": ("ETHUSDT",)},
}

Don’t insert one row per tick from every worker

At one tick per second per symbol, per-row INSERT from many workers will melt your database. Batch. Push ticks onto a Redis list and have a single writer drain it with execute_values or COPY.

import psycopg2
from psycopg2.extras import execute_values


def flush_ticks(conn, batch: list[dict]) -> None:
    with conn.cursor() as cur:
        execute_values(
            cur,
            """
            INSERT INTO price_history (time, symbol, open, high, low, close, volume)
            VALUES %s
            """,
            [
                (t["ts"], t["symbol"], t["price"], t["high"], t["low"], t["price"], t["volume"])
                for t in batch
            ],
            page_size=1000,
        )
    conn.commit()

For historical backfills (the “load six months of candles” job), use COPY ... FROM STDIN, which is an order of magnitude faster than inserts. Backfills belong in a separate queue so they never starve live polling.

The Data Flow, End to End

Exchange REST
     │  (Celery beat schedules, worker polls)
     ▼
Celery worker ──SETEX──►  Redis: ticker:BTCUSDT        (snapshot cache)
     │
     ├──PUBLISH──► Redis: prices.BTCUSDT ──► Litestar subscriber ──► WS hub ──► Browser
     │
     └──queue────► writer ──batch INSERT──► Postgres/TimescaleDB: price_history
                                                     │
                                                     ▼
                                            candles_1m (continuous agg)

Browser ──REST/HTTPS──► Django (admin, auth, portfolios) ──ORM──► Postgres

A client connects to wss://.../ws/tickers/BTCUSDT:

  1. Litestar accepts, joins room ticker:BTCUSDT.
  2. It sends the cached snapshot from Redis (instant first paint).
  3. The next Celery tick publishes to Redis; Litestar’s subscriber broadcasts to the room.
  4. Django serves the portfolio page and the admin separately over HTTPS.

Two connections, two protocols, one database. Each half does what it’s good at.

How to Wire Them Together

The two halves are independent services, but they’re one system. Four seams connect them: the database, Redis, auth, and the reverse proxy. Get these right and Django and Litestar stay decoupled. Get them wrong and you’ve built a distributed monolith with two runtimes to debug.

Shared Postgres: One Schema, One Migration Authority

Both apps point at the same database. The rule that keeps it sane: Django owns migrations and the relational tables; Litestar reads through SQLAlchemy/asyncpg and writes only to hot, append-only tables it’s been given. Never import Django models into Litestar or call django.setup() there — that couples your deploys and drags a sync ORM into an async event loop.

Django is the migration authority because it already does that job well:

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "trading",
        "USER": "django_app",          # runtime role; migrator owns DDL
        "PASSWORD": env("DB_PASSWORD"),
        "HOST": "postgres",
        "PORT": 5432,
        "CONN_MAX_AGE": 60,
        "OPTIONS": {"connect_timeout": 5},
    }
}

Litestar connects with its own, less-privileged role and describes the few tables it needs as explicit SQLAlchemy Table objects rather than reflecting the whole schema:

# realtime/db.py
from sqlalchemy import MetaData, Table, Column, Text, Numeric, TIMESTAMP
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://litestar_ro:...@postgres:5432/trading",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,          # survive Postgres restarts
)
Session = async_sessionmaker(engine, expire_on_commit=False)

metadata = MetaData()

price_history = Table(
    "price_history", metadata,                       # mirrors the Django migration
    Column("time", TIMESTAMP(timezone=True), primary_key=True),
    Column("symbol", Text, primary_key=True),
    Column("close", Numeric),                        # NUMERIC -> Decimal, never float
)
# realtime/queries.py — Litestar reads; Django still owns the schema
from sqlalchemy import select

async def latest_close(session, symbol: str):
    stmt = (
        select(price_history.c.close)
        .where(price_history.c.symbol == symbol)
        .order_by(price_history.c.time.desc())
        .limit(1)
    )
    return (await session.execute(stmt)).scalar_one_or_none()

The safety comes from two database roles, not from discipline:

-- Migrations/DDL and relational writes run as django_app (table owner).
-- Litestar gets a role that can only do what it's supposed to.
GRANT USAGE  ON SCHEMA public TO litestar_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO litestar_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO litestar_ro;

-- If Litestar must append ticks, grant INSERT on just that table:
GRANT INSERT ON price_history TO litestar_ro;

Run manage.py migrate as a single deploy step (a one-shot Job) before either service rolls out. Litestar never runs DDL, so there’s exactly one schema history and no Alembic-versus-Django conflict. And watch the connection budget: Django’s pool + Litestar’s pool + Celery + workers must stay under Postgres max_connections (or put PgBouncer in front).

Shared Redis: The Event Bridge (Never Import Each Other)

Redis is how the two runtimes talk without a shared Python package. Define a versioned envelope once and treat it as your internal API:

{ "v": 1, "type": "asset.updated", "ts": "2026-10-08T12:00:00Z",
  "payload": { "symbol": "BTCUSDT", "is_active": false } }

Django publishes domain events when relational state changes — but only after the transaction commits, or you’ll announce changes that rolled back:

# core/events.py
import json
import redis
from django.db import transaction

r = redis.Redis.from_url("redis://redis:6379")

def publish_event(event_type: str, payload: dict) -> None:
    message = json.dumps({"v": 1, "type": event_type, "payload": payload})
    # Fire only for transactions that actually persisted.
    transaction.on_commit(lambda: r.publish(f"evt:{event_type}", message))

Litestar subscribes and reacts — refresh a cached asset list, or push a notice over the socket it already owns:

# realtime/events.py
import orjson
import redis.asyncio as redis

async def consume_domain_events() -> None:
    client = redis.from_url("redis://redis:6379")
    pubsub = client.pubsub()
    await pubsub.psubscribe("evt:*")            # evt:asset.updated, evt:portfolio.updated
    async for msg in pubsub.listen():
        if msg["type"] != "pmessage":
            continue
        event = orjson.loads(msg["data"])
        await handle_domain_event(event["type"], event["payload"])

The direction already baked into this design is the reverse for prices (Celery → Redis → Litestar). Both directions use the same envelope and the same namespaced channels, so “who publishes” becomes a per-event decision, not an architectural one. Three rules: version the envelope, namespace the channels, and make every consumer idempotent — at-least-once delivery means the same event can arrive twice.

Use pub/sub for live fan-out and Redis Streams (XADD/XREADGROUP) whenever an event must not be lost. Pub/sub with no subscriber is a message that never happened.

Auth Bridging: Django Issues, Litestar Verifies

Don’t build a second user system. Django authenticates and Litestar verifies a token Django signed, using a shared secret or a public key.

The recommended path is JWT with djangorestframework-simplejwt:

# settings.py (Django)
INSTALLED_APPS += ["rest_framework", "rest_framework_simplejwt"]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

from datetime import timedelta
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ALGORITHM": "HS256",
    "SIGNING_KEY": env("JWT_SIGNING_KEY"),   # shared with Litestar
    "AUDIENCE": "api",
    "ISSUER": "django",
}
# urls.py (Django) — the token endpoints the SPA calls
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns += [
    path("app/api/token/", TokenObtainPairView.as_view()),
    path("app/api/token/refresh/", TokenRefreshView.as_view()),
]

Litestar verifies the same token with the same key, and — critically — pins the accepted algorithm and claims:

# realtime/auth.py
import os
import jwt
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult

JWT_SECRET = os.environ["JWT_SIGNING_KEY"]

class JWTAuth(AbstractAuthenticationMiddleware):
    async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
        scheme, _, token = connection.headers.get("authorization", "").partition(" ")
        if scheme.lower() != "bearer" or not token:
            raise NotAuthorizedException("missing bearer token")
        try:
            claims = jwt.decode(
                token,
                JWT_SECRET,
                algorithms=["HS256"],          # never trust the token's own `alg`
                audience="api",
                issuer="django",
            )
        except jwt.PyJWTError as exc:
            raise NotAuthorizedException("invalid token") from exc
        return AuthenticationResult(user=claims, auth=claims)

If you’d rather Litestar couldn’t mint tokens — it only holds a verification key today — switch to RS256/EdDSA: Django signs with a private key, Litestar verifies with the public key (or a JWKS endpoint). Same code, algorithms=["RS256"] and the public key.

Two practical notes:

  • Access tokens are stateless. For logout/revocation, put the token’s jti on a short Redis denylist and check it during verification. Keep access tokens short-lived (minutes); make refresh tokens long-lived and revocable.
  • Browsers can’t set an Authorization header on a WebSocket handshake. Pass the token via Sec-WebSocket-Protocol (preferred) or a query param (beware logs), and validate it in on_accept before joining a room.

The alternative — Redis-backed sessions — is lighter when both apps are same-origin: set SESSION_ENGINE = "django.contrib.sessions.backends.cache" with django-redis, then Litestar reads the sessionid cookie and loads the session blob straight from Redis. It’s less code, but it couples Litestar to Django’s internal session format and turns every auth check into a Redis round-trip. For service-to-service and cross-origin calls, JWT is the cleaner seam.

Deployment: Nginx/Traefik, One Front Door

Expose one hostname and route by path: Django owns /admin and /app, Litestar owns /api and /ws.

upstream django   { server django:8000; }
upstream litestar { server litestar:8000; }

server {
    listen 443 ssl;
    server_name app.example.com;

    location /admin/ { proxy_pass http://django; }
    location /app/   { proxy_pass http://django; }

    location /api/   { proxy_pass http://litestar; }

    location /ws/ {
        proxy_pass http://litestar;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;   # hold long-lived sockets open
        proxy_send_timeout 3600s;
    }
}

The same split in Traefik, driven by Docker labels:

services:
  django:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.django.rule=Host(`app.example.com`) && (PathPrefix(`/admin`) || PathPrefix(`/app`))"
      - "traefik.http.services.django.loadbalancer.server.port=8000"
  litestar:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.litestar.rule=Host(`app.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/ws`))"
      - "traefik.http.services.litestar.loadbalancer.server.port=8000"

Then the operational rules: run migrations once before rollout; scale Litestar independently of Django; no sticky sessions needed, because each Litestar replica subscribes to Redis and pushes only to its own sockets; give each service a /healthz; and serve static/media from a CDN or nginx rather than Django in production.

Local dev is one docker-compose.yml: timescale/timescaledb, redis, a django service (runserver), a litestar service (uvicorn --reload), and celery worker + celery beat sharing the image.

That’s the whole contract: Postgres for durable truth (Django migrates), Redis for events (either side publishes), JWT for identity (Django issues, Litestar verifies), one reverse proxy for routing. Four seams — and the two frameworks never need to import each other.

The Gotchas Nobody Mentions First

Backpressure will kill you before scale does. A slow client (mobile, throttled) can’t consume ticks as fast as they arrive. If you await ws.send() in the broadcast loop without bounding it, one laggy client back-pressures the whole room. Give each connection a bounded queue and coalesce — drop intermediate ticks and send the latest. For a live price, staleness beats lag.

WebSocket auth is not HTTP auth. Cookies don’t flow cleanly on cross-origin WS handshakes. Use a short-lived token in the query string or the Sec-WebSocket-Protocol header, validate it on accept, and reject before joining a room.

Floats for money are a bug waiting to happen. 0.1 + 0.2 != 0.3 in binary float, and it’s worse once you sum a portfolio. Store NUMERIC/Decimal; convert to float only at the display layer.

Everyone forgets UTC. Store TIMESTAMPTZ, generate timestamps in UTC, and convert in the browser with the user’s locale. Timezone bugs in a time-series app surface as “the 1-minute candle is wrong by an hour twice a year.”

Redis pub/sub is not a queue. No subscribers means no message. If a tick must be durable (fills, trades, orders), that’s a Stream or Kafka decision, not a pub/sub decision.

Two ORMs, one schema. Decide who owns each table and enforce it with database permissions if you have to. Django migrations are the schema history; Litestar should read, or write only to tables with no ORM model.

Polling is a rate-limit problem, not a loop. Batch your symbol list into a single multi-symbol endpoint when the exchange offers one, respect weight limits, and back off on 429 with jitter.

TL;DR

  • Litestar owns the async edge: WebSocket rooms, in-process hub, one Redis subscriber per process fanning out to local sockets.
  • Django owns the back office: admin, auth, and the ORM for assets, users, and portfolios. One writer per table.
  • Redis is the seam: pub/sub for fan-out, SETEX for hot-ticker snapshots, Lua for exchange rate limits. It is not durable — use Streams if you need replay.
  • Postgres/TimescaleDB holds both worlds: a price_history hypertable with continuous aggregates and retention, plus relational portfolio tables, in one database so valuation joins stay local.
  • Celery pulls the outside world in: beat schedules polling, workers fetch/cache/publish, and a single batched writer keeps ticks off the hot path.
  • Two connections, two protocols, one source of truth. Route by path, scale each side independently, and never let one framework do the other’s job.

The win isn’t the list of tools. It’s that each one is doing the thing it’s genuinely best at, with Redis as the thin seam between the fast, lossy, live world and the slow, exact, durable one.

Comments

Leave a comment

What is 2 + 3?
Please solve the arithmetic question above to verify you're human.