Skip to main content
§ Writing

The Stack Evolution

Six packs from sync baseline to specialized infra — each one triggered by a specific pain point, never by chasing newness. Django + Next.js on a real stack progression.

by Bogdan#architecture#django#next.js#asgi#celery#websockets

There are two recurring failure modes in stack progression. The first is jumping ahead of the pain — adopting WebSockets when SSE would have done, or specialized infra when Postgres still had headroom. The second is staying too long on a pack that's no longer fit — workers thrashing, queues backing up, polling clients hammering endpoints that should have streamed.

This essay is the map of when to make each jump. Django + DRF on the back, Next.js 16 on the front. Six packs, each one triggered by a specific pain point — never by chasing newness. Stay on the lowest pack that solves the actual problem.

01
pack one

Sync Baseline

— one round trip, done —

stack.syncsync only
frontend
Next.js 16RSC + Server Actions

HTTP (request → response)

backend
gunicornWSGI process manager · sync workers
Django + DRFsync views, ORM, serializers
PostgreSQLprimary store

The contract is simple: client sends, server thinks, server replies, connection closes. Every framework feature is built around this loop — middleware, serializers, the ORM, all of it.

Beautiful while it lasts. Most CRUD apps never need to leave this pack.

Trigger →

Some work takes longer than a user will wait. The browser times out. The worker stays blocked. Some work needs to happen on a schedule.

02
pack two

Background Work

— punt slow stuff to a worker —

stack.sync + queuesync HTTP · async jobs
frontend
Next.js 16RSC + polling /jobs/{id}

↕ HTTP + poll loop every 2s

web tier
gunicornWSGI · sync workers
Django + DRFsync views
queue tier
Celery workerslong jobs · ML · email · reports
NEW
Celery Beatcron-like scheduler
NEW
Redisbroker + result backend
NEW
data
PostgreSQLprimary store

Slow work moves out of the request path. The view dispatches a Celery task, returns a job id, and the frontend polls until status is done. Beat handles scheduled work — nightly digests, hourly cleanups, periodic syncs.

This is the workhorse setup for most SaaS. It will get you very far.

Trigger →

Polling is wasteful — most calls return nothing new. Sync views still block on slow external HTTP. We want to push live updates instead of asking for them.

03
pack three

Async + Streaming

— ASGI unlocks the long connection —

stack.asyncasync views · SSE
frontend
Next.js 16 · 'use client'EventSource subscription
NEW

HTTP + SSE stream (server pushes events)

web tier
gunicorn + UvicornWorkerprocess manager · ASGI workers
SWAP
Django + DRF on ASGIasync views, async ORM (4.1+)
SWAP
StreamingHttpResponseyield SSE events
NEW
queue tier
Celery + Beat + Redisunchanged
data
PostgreSQLprimary store

Two new powers. First, async views: a worker that hits await httpx.get(...) sets the request aside and serves others until the response arrives. One worker juggles hundreds of concurrent slow I/O calls.

Second, streaming responses: yield SSE events as a Celery job progresses. The frontend opens one EventSource and receives "20% · 50% · done" pushes. The poll loop dies.

Same Django, same DRF — the protocol it speaks just changes from WSGI to ASGI underneath.

Trigger →

SSE is one-way. We need bidirectional — chat, multiplayer state, collaborative editing, presence indicators. Multiple clients syncing the same view.

04
pack four

WebSockets

— full duplex, persistent —

stack.realtimebidirectional
frontend
Next.js 16 · 'use client'new WebSocket(wss://…)
NEW

WebSocket (persistent, full-duplex)

web tier
Daphne · or · UvicornASGI server with WS protocol
SWAP
Django ChannelsAsyncWebsocketConsumer · routing · groups
NEW
Django + DRF on ASGIunchanged for HTTP routes
queue + bus
Redis · channel layercross-process pub/sub for sockets
NEW
Celery + Beatworkers can push to sockets via channel layer
data
PostgreSQLprimary store

Channels extends Django past request/response. Consumer classes route WebSocket connections like URL routes; the channel layer (Redis) lets any process — another web worker, a Celery task, a management command — push a message into a specific socket or a group of them.

Daphne or Uvicorn — pick one. Daphne is Channels-native; Uvicorn is faster on plain HTTP. Both work.

Trigger →

One server isn't enough. Workers thrash. Postgres reads pile up. WebSocket reconnect storms during deploys. Time to spread out.

05
pack five

Scale-out

— horizontal everything —

stack.distributedN replicas
edge
Load balancersticky sessions for WS · health checks
NEW
web tier (×N)
Django ASGI replicasstateless · horizontally scaled
Daphne / Uvicorn replicasbehind the LB
queue tier (split)
Celery queues by priority-Q high,default,low · separate worker pools
NEW
Redis · split instancesone for Celery broker · one for Channels layer
NEW
data
Postgres + read replicaswrites to primary · reads to replica via router
NEW
observability
Flower · Prometheus · Grafana · Sentryqueue depth · latency · errors
NEW

The composition doesn't change much — the multiplicity does. Same components, more of them, with seams between them you can scale independently.

The two seams that matter most: queue priority split (so a slow ML job can't starve a fast email job) and Redis split (Celery's traffic profile and Channels' traffic profile are nothing alike — sharing one Redis is asking for noisy-neighbor pain).

Trigger →

The general-purpose stack stops being good enough for a specific workload. Postgres can't index your embeddings. The VPS disk is full of user uploads. ML inference is hot path.

06
pack six

Specialized Infra

— the right tool, where it belongs —

stack.specializedper-workload
storage
S3 / R2user uploads off the VPS disk
OPT
search + vectors
pgvector · Qdrantsemantic search · embeddings index
OPT
Elasticsearch · Meilisearchfull-text search beyond ILIKE
OPT
analytics
ClickHousewhen Postgres analytics queries get slow
OPT
ML serving
Triton · Ray Serve · FastAPI sidecardedicated inference · GPU-aware
OPT
edge + delivery
CDN + Next.js ISR / edgecache RSC · static at the edge
OPT
Pusher · Ably · Soketimanaged WebSockets if you don't want to run Channels
OPT

None of these are mandatory. Each appears when a specific workload outgrows the general-purpose stack. The art is not adopting them prematurely — every new piece of infra is a new operational surface.

The discipline carries across the whole evolution: each pack is a response to a real pain, never a response to wanting newer tools.

Adopt the lowest pack that solves your actual problem.

Most projects never need pack 04. Many teams adopt Channels and realize SSE was enough. The trigger is always pain, never novelty.