CJSF Radio
Split the campus radio station's PHP app into services so the live stream stays up, fixed a race condition that broke about 15% of scheduled shows, and built archive search, live notifications and listener analytics.
- 300+
- concurrent listeners
- 80K+
- recordings searchable
- 90%
- fewer analytics writes
Backend Developer · Apr to Jul 2025
Python, FastAPI, PHP, Laravel, PostgreSQL, Redis, Elasticsearch, WebSockets, Nginx, Docker, FFmpeg
What it is
CJSF 90.1 FM is SFU's campus and community radio station. Its website, show schedule, the job that starts each broadcast, the live stream and the recording archive all ran in one Laravel app on one server. A memory spike on an archive page, or a crash in the stream encoder, took everything down, including the live stream people were listening to.
Splitting the app
The main change was isolating streaming. The streaming service turns the live feed into HLS segments with FFmpeg and shares nothing with the website. If the web app is redeployed, overloaded or crashes, Nginx keeps serving the playlist and segments, and the 300+ people listening don't notice.
- client
- service
- database or storage
- queue or stream
- bold outline = what I owned
- request
- async: event, message, webhook
- Listeners and hosts reach Nginx.
- Nginx routes each path to its container: the website and scheduler, the live stream, or the archive API.
- The streaming service publishes listener events to Redis.
- The archive and realtime service consumes events from Redis.
- It searches the archive in Elasticsearch.
The migration was gradual. Nginx went in front of the old app first, then routes moved to the new services one at a time, streaming first because it mattered most. The old path stayed available as a fallback until the new one had proven itself.
The race condition
About 15% of scheduled broadcasts were failing, with nothing obvious in the logs. The scheduler ran as a periodic job, and when one run took longer than its interval, two workers overlapped. Both checked that a time slot was free, both saw yes, and both claimed it. I fixed it with a Redis distributed lock on each time-slot claim, so only one worker can ever own a slot.
- service
- database or storage
- request
- failure path
Search that forgives typos
The archive has 80,000+ recordings with show, host, date, description and tags. SQL LIKE queries were slow and bad at relevance. Now a FastAPI service indexes the metadata in Elasticsearch, with full-text search on titles and descriptions and filters on show, host and date. Fuzzy matching means a search still finds the right record when a show or artist name is misspelled. It handles 500+ concurrent users.
PostgreSQL stays the source of truth. The index updates when a recording is added or edited, and it can be rebuilt from Postgres at any time.
Live notifications
Hosts wanted to know when their show went live, and listeners wanted "now playing" updates without refreshing. Services publish events to Redis Streams, and the FastAPI service reads them with a consumer group and pushes them to browsers over WebSockets in under 200ms end to end. With Streams, a consumer that briefly disconnects catches up on what it missed instead of losing it.
- client
- service
- database or storage
- queue or stream
- scheduled job
- bold outline = what I owned
- request
- async: event, message, webhook
- A listener or staff member searches from the web client.
- The FastAPI service runs a fuzzy search in Elasticsearch.
- When a new item is added to the archive, the service publishes an event to Redis Streams.
- The WebSocket gateway picks the event up.
- It pushes the update to every connected client, in under 200ms end to end.
Listener analytics
The station wanted listener counts over time. The first version wrote one database row for every listener joining or leaving, which hammered the database during popular shows. Now the streaming service publishes those events over Redis pub/sub, an aggregator counts them in memory per show and interval, and it writes one batched insert per interval into a time-series table. That cut database writes by about 90% with no loss of detail the dashboards use.
DJLand
I also worked on DJLand, the platform CJSF's DJs and staff use to run the station: moving its old Laravel frontend to Next.js without touching the backend underneath.
Key decisions
| Decision | Instead of | Why |
|---|---|---|
| Streaming in its own service | Keeping it in the monolith | The live stream is the product; nothing else should take it down |
| Gradual migration behind Nginx | A full rewrite | One route at a time, with a fallback |
| Elasticsearch, with Postgres as the source of truth | SQL LIKE queries | Relevance and speed; the index can always be rebuilt |
| Redis Streams for notifications | Plain pub/sub | Consumers catch up after a disconnect |
| Batched analytics writes | One row per event | About 90% fewer writes |
Results
- concurrent listeners
- 300+
- records searchable
- 80K+
- search users
- 500+ concurrent
- notifications
- <200ms end to end
- failed broadcasts
- -15pp
- analytics DB writes
- -90%