Building a Real-Time Fundraising Platform: Why We Bet on Django, Wagtail, Rust, and SSE
How we architected a multi-tenant fundraising event platform that handles thousands of concurrent donation streams, per-organisation theming, and sub-second live updates - with Django, Wagtail CMS, a Rust SSE microservice, and zero WebSocket complexity.
The Problem: A Fundraising Event Is a 24-Hour Sprint
Imagine a university rallying 50,000 alumni to donate in a single day. A thermometer on the homepage climbs in real time. Leaderboards shuffle. Ambassadors watch their personal fundraising pages tick upward. Behind the scenes, thousands of browser tabs hold open connections, waiting for the next donation event.
That is a fundraising event - and when we set out to build the platform that powers them, we knew the tech stack had to satisfy three non-negotiable requirements:
- Real-time at scale - thousands of concurrent SSE connections with sub-second latency
- Content flexibility - every organisation needs its own brand, pages, campaigns, and causes, managed by non-technical staff
- Operational simplicity - a small team must ship features fast, not babysit infrastructure
Here is how we chose - and justified - every layer of the stack.
Django + Wagtail: The Content Powerhouse
The core web application runs on Django 5.x with Wagtail 7.x as the CMS layer. This was never a close call. Wagtail gives us:
- StreamField - campaign pages are composed from reusable blocks (hero banners, thermometers, leaderboards, donor walls, cause grids) that content editors drag and drop. No developer needed for page layout changes.
- Snippet-driven configuration - challenges, leaderboard rules, matching gifts, and cause designations are all Wagtail snippets. Editors create a challenge, set a time window and dollar target, and the platform handles the rest.
- Preview and revision history - during a live fundraising event, editors can draft page updates, preview them, and publish instantly without a deployment.
Wagtail is not just "WordPress but in Python." It is a genuinely developer-friendly CMS that stays out of your way when you need custom business logic but gives content teams real autonomy.
# Campaign page built from composable StreamField blocks
class CampaignPage(WagtailCacheMixin, Page):
body = StreamField([
("hero", HeroBannerBlock()),
("thermometer", ThermometerBlock()),
("leaderboard", LeaderboardBlock()),
("donor_wall", DonorWallBlock()),
("cause_grid", CauseGridBlock()),
("rich_text", blocks.RichTextBlock()),
("video_embed", EmbedBlock()),
])
campaign = models.ForeignKey(
"campaigns.Campaign", on_delete=models.PROTECT
)
Multi-Tenant Architecture: One Codebase, Isolated Data
The platform isolates each organisation's data at the database level. A custom database router directs queries to the correct backend based on the current tenant context, while shared resources like page content and campaign configuration live in a common store.
Why full database-level isolation instead of row-level filtering?
- Data sovereignty - some organisations require that their donor data is physically separated. Isolated databases make compliance audits trivial.
- Performance isolation - a fundraising event that generates 10,000 donations in an hour does not slow down queries for other organisations.
# Dynamic tenant database registration
def ensure_database_registered(org):
db_alias = f"tenant_{org.sanitized_id}"
if db_alias in connections.databases:
return db_alias
connections.databases[db_alias] = {
"ENGINE": "django.db.backends...",
"NAME": db_alias,
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
return db_alias
Real-Time Donations: Why SSE Beats WebSockets Here
The signature feature of a fundraising event is the live donation feed. Every browser tab showing the campaign page holds an open connection and receives donation events the instant they are processed.
We chose Server-Sent Events (SSE) over WebSockets for a deliberate reason: the data flow is unidirectional. The server pushes donation events to clients. Clients never send data back through the event stream. SSE gives us:
- Automatic reconnection - the browser's native
EventSourceAPI reconnects on disconnect with exponential backoff. Zero client-side reconnection logic. - HTTP/2 multiplexing - SSE streams share the same TCP connection as other HTTP requests. No separate protocol, no CORS preflight dance, no special proxy configuration.
- Load balancer transparency - SSE is just a long-lived HTTP response. Every reverse proxy, CDN, and load balancer already understands it.
- Simplicity - no ping/pong frames, no connection upgrade handshake, no binary framing. Just UTF-8 text over HTTP.
WebSockets would be the right choice if we needed bidirectional communication - a chat feature, collaborative editing, or interactive auctions. For a donation feed, SSE is the right tool.
The Rust SSE Microservice: Why Not Just Use Django?
Django is synchronous. Gunicorn workers handle one request at a time. Holding open 5,000 SSE connections would require 5,000 Gunicorn workers - a non-starter.
We built a dedicated SSE microservice, and we built it twice: first in Python with FastAPI + Hypercorn (async ASGI), then in Rust with Axum + Tokio. Both follow the same architecture:
- Client opens
EventSourceto the SSE service - SSE service subscribes to a Redis pub/sub channel:
campaign:{id}:updates - When Django processes a donation, a Celery task publishes updated stats to Redis
- SSE service receives the message and fans it out to all connected clients for that campaign
The Rust version exists because we wanted to push the concurrency ceiling even higher. A single Rust process can comfortably hold tens of thousands of concurrent connections with minimal memory overhead, thanks to Tokio's zero-cost async runtime. In practice, the FastAPI version handles our current load perfectly - the Rust service is insurance for the day a campaign goes truly viral.
// Rust SSE handler (Axum + Tokio)
pub async fn sse_handler(
State(state): State<AppState>,
Path(campaign_id): Path<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
// Atomic connection count via Lua script
let count = state.redis
.increment_connection(campaign_id)
.await;
if count > MAX_CONNECTIONS {
yield Ok(Event::default()
.data("rate_limited"));
return;
}
// Subscribe to Redis pub/sub channel
let mut sub = state.redis
.subscribe(campaign_id).await;
while let Some(msg) = sub.next().await {
yield Ok(Event::default()
.data(msg.get_payload::<String>()?));
}
};
Sse::new(stream)
.keep_alive(KeepAlive::default())
}
Thundering Herd Prevention with Lua Scripts
One subtle problem with SSE at scale: when a popular campaign page loads, hundreds of browsers simultaneously open SSE connections. If we check the connection count in Python, increment it, then decide whether to accept - there is a race condition. Two hundred clients can all read "199 connections" and all get through.
We solved this with an atomic Lua script executed inside Redis. The script reads the current count, checks against the limit, and increments in a single atomic operation. No TOCTOU race, no distributed lock needed.
-- Atomic connection limiting (runs inside Redis)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return -1 -- rejected
end
local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count -- accepted
Per-Organisation Theming: CSS Variables at Scale
Every organisation on the platform has its own brand - colours, logos, fonts. We needed a theming system that is:
- Configurable by non-developers through the CMS
- Performant - no runtime JavaScript colour calculations
- Dark-mode-aware from day one
The solution: a BrandColor Wagtail model that generates CSS custom properties server-side. Each organisation defines their palette (primary, secondary, accent, background) with optional dark-mode overrides. The platform renders these as a <style> block in the page head.
class BrandColor(models.Model):
color_value = models.CharField() # light mode
color_value_dark = models.CharField() # dark mode override
is_dark = models.BooleanField() # text contrast hint
@classmethod
def get_all_css_for_organization(cls, org) -> str:
"""Generates complete CSS variable blocks."""
colors = cls.objects.filter(organization=org)
light = "; ".join(
f"{c.css_var_name}: {c.color_value}" for c in colors
)
dark = "; ".join(
f"{c.css_var_name}: {c.color_value_dark}"
for c in colors if c.color_value_dark
)
return (
f":root {{ {light} }}\n"
f'[data-theme="dark"] {{ {dark} }}\n'
f"@media (prefers-color-scheme: dark) "
f"{{ :root:not([data-theme]) {{ {dark} }} }}"
)
This three-tier approach covers every scenario: explicit user toggle (data-theme attribute), system preference via prefers-color-scheme, and a sensible default. The Tailwind classes throughout the templates reference these variables instead of hard-coded colours, so one organisation can be deep navy blue and another can be bright crimson - same HTML, different paint.
HTMX: The Anti-SPA
The frontend is server-rendered Django templates enhanced with HTMX and Alpine.js. There is no React, no Vue, no build step for JavaScript. This is a deliberate architectural choice.
Fundraising event pages are heavily cached. The campaign page HTML is generated once, cached in Redis, and served to thousands of visitors. A React SPA would require a separate API layer, introduce client-side caching complexity, and double the surface area for bugs - all to achieve the same result as a cached server-rendered page.
HTMX gives us the interactivity we need with surgical precision:
- Donor list pagination -
hx-getfetches the next page of donors and swaps it into the DOM. The server returns a partial HTML fragment, not JSON. - Lazy-loaded sections - heavy leaderboard calculations are deferred. The page loads instantly with a placeholder, then HTMX fires a request to fill it in.
- Shell + Partial pattern - full page loads return the complete HTML shell. HTMX requests (detected via the
HX-Requestheader) return just the fragment. Same view, same template, zero duplication.
<!-- Donor list with HTMX pagination -->
<div id="donors-list"
hx-get="?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Skeleton placeholder -->
</div>
<!-- Server detects HX-Request header, returns partial -->
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#donors-list"
hx-swap="innerHTML"
class="btn-outline">Load more</a>
{% endif %}
Performance: From 70+ Queries to 9
The biggest performance win came not from caching but from eliminating N+1 queries. Campaign pages involve deeply nested relationships: campaigns have causes, causes have leaderboards, leaderboards have teams, teams have members. A naive ORM traversal generated 70+ SQL queries per page load.
We used three strategies to bring this down to 9 queries:
select_relatedfor single-valued foreign keys (campaign → organisation, donation → cause)prefetch_relatedfor reverse and many-to-many relations (campaign → causes, leaderboard → teams)- Manual prefetch cache injection for Wagtail's ClusterableModel objects that bypass Django's standard prefetch machinery. We populate
_prefetched_objects_cachedirectly after a bulk query.
# Manual prefetch cache for ClusterableModel
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in leaderboards]
# Single bulk query for all teams across all leaderboards
teams = FlexiLeaderboardTeam.objects.filter(
leaderboard_id__in=leaderboard_ids
).select_related("team_profile")
teams_by_lb = defaultdict(list)
for t in teams:
teams_by_lb[t.leaderboard_id].append(t)
# Inject into prefetch cache - avoids N+1
for lb in leaderboards:
lb._prefetched_objects_cache = {
"leaderboard_team_links": teams_by_lb.get(lb.pk, []),
}
Cache Warming: Don't Wait for the First Request
Caching is only half the story - the first request after a cache miss can be painfully slow if the page is expensive to render. We solve this with proactive cache warming: when a donation arrives and stats are recalculated, a Celery task renders the campaign page using Django's test Client and primes the cache before any real user hits it.
CDN warming is trickier. We fire HTTP requests to the CDN edge with rate limiting (2 concurrent, 200ms between requests) to avoid overwhelming the origin. A SETNX deduplication key in Redis prevents multiple Celery workers from warming the same page simultaneously.
Deployment: Kubernetes + Helm
The production stack runs on Kubernetes orchestrated with Helm charts. Each service is independently scalable:
- Django (Gunicorn) - 2-4 replicas behind a service mesh
- SSE service (Rust/FastAPI) - scales horizontally based on connection count
- Celery workers - separate queues for stats calculation, cache warming, and donation processing
- Celery Beat - single replica for periodic tasks
- Nginx - reverse proxy with HTTP/2 and TLS termination
- Redis Sentinel - high-availability Redis cluster
Observability is handled by OpenTelemetry + Jaeger for distributed tracing, Sentry for error tracking, and Prometheus + Grafana for metrics and alerting. Every donation has a trace ID that follows it from the API endpoint through Celery tasks to the SSE broadcast.
The Stack, Justified
Every technology in this stack earns its place:
- Django - battle-tested ORM, middleware, auth, admin. The ecosystem is unmatched for a content-heavy platform.
- Wagtail - gives content teams real power without sacrificing developer ergonomics. StreamField alone justifies it.
- SSE over WebSockets - simpler, more resilient, and the data flow is unidirectional anyway.
- Rust for SSE - holds tens of thousands of idle connections with near-zero memory overhead. The right tool for the right job.
- Redis - pub/sub for real-time fan-out, caching for page performance, Lua scripts for atomic operations. One service, three critical roles.
- HTMX over React - cached server-rendered HTML is faster, simpler, and more accessible than a client-rendered SPA for this use case.
- Per-org databases - data isolation without the complexity of row-level tenancy.
- Kubernetes + Helm - independent scaling of stateless services during peak fundraising event traffic.
There is no silver bullet in software architecture. But there are stacks that fit the problem shape, and this one fits like a glove. The platform has successfully powered fundraising events that raised millions of dollars, with real-time feeds running smoothly across thousands of concurrent connections - and the content team ships page changes without a single line of code.
The best architecture is the one where every component can explain why it exists. If you cannot justify a technology's presence in two sentences, it probably should not be there.