@MartxDev
Back to blog

Case study: architecting a physiotherapy e-learning SaaS with Go, React and WebSockets

Designing a subscription e-learning platform in 2024: recurring Stripe billing, protected low-latency video on Bunny.net, and a real-time support channel — built lean by a two-person team.

In 2024, together with a development partner, I took on the design and build of a custom e-learning platform for a physiotherapy clinic. The business goal was to package the clinic’s expertise into on-demand content behind a monthly subscription.

Technically, that meant three non-negotiables: recurring payments that just work, high-availability video streaming with low latency, and a direct, synchronous channel between patients/students and the clinic’s specialists. Here’s how the architecture came together and how we solved the engineering problems underneath it.

Stack and the reasoning

  • Go (backend). Fast execution, a single compiled binary, and — crucially — first-class concurrency via goroutines and channels, which the real-time messaging layer leaned on heavily.
  • React (frontend). A modular, reactive UI with clean global-state handling for the video player and the chat lifecycle.
  • PostgreSQL (database). Strict ACID guarantees, comfortable with complex progress queries, and excellent JSONB support for audit logs and dynamic configuration.
  • Bunny.net (storage + CDN). We deliberately skipped traditional object storage like S3 in favor of Bunny.net’s native video optimization (Edge Storage + Stream), aggressive pricing, and built-in encoding and content protection.
  • Stripe (payments). Subscriptions, billing cycles, dunning (failed-payment retries) and PCI-DSS compliance are all things you should not reinvent.

Three core modules

1. Subscription engine (Stripe)

Monetization was fully automated. The Go backend consumed Stripe webhooks as the source of truth: a successful monthly charge extended the user’s access in PostgreSQL; a failed charge automatically suspended their media permissions and routed them to Stripe’s billing portal.

2. Protected streaming (Bunny.net)

All course media lived on Bunny.net, which transcoded uploads into multiple resolutions to adapt to each viewer’s bandwidth. To stop hotlinking — playback outside the platform — the Go backend issued signed URLs with short expiry (token authentication), so only users with an active subscription could ever load a video.

3. Real-time support channel (WebSockets)

The platform’s competitive edge was letting students consult the clinic’s physiotherapists directly. We built a native WebSocket server in Go on top of the Fiber framework, using a Hub/Room pattern to manage active connections and route messages bidirectionally.

The hard problems

Concurrency + persistence in the live chat

The risk: if writing each message to the database sat on the hot path, any database latency would make the chat feel sluggish. We didn’t want delivery to wait on persistence.

Solution: deliver first, persist asynchronously. When a message arrives, the server pushes it straight to the connected specialist over the live socket, and also drops it onto a buffered Go channel. A background worker pool drains that channel and writes to PostgreSQL out of band:

// Delivery is instant; persistence happens off the hot path.
func (h *Hub) Handle(msg Message) {
    h.routeToRoom(msg)        // 1. deliver immediately over the socket
    select {
    case h.persistQueue <- msg: // 2. hand off to the worker pool
    default:
        log.Warn("persist queue full, applying backpressure")
    }
}

// One of N workers draining the queue into PostgreSQL.
func (w *Worker) run() {
    for msg := range w.queue {
        if err := w.repo.SaveMessage(context.Background(), msg); err != nil {
            w.retry(msg)
        }
    }
}

The user never feels the database; the conversation history still ends up durable.

A subscription lifecycle that never leaks access

Monthly subscriptions have messy states — active, past-due, cancelled in a grace period, suspended — and each one had to map perfectly onto access to the Bunny.net content. We modeled it as an explicit state machine in the backend, driven by Stripe events (customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded). Every critical transition immediately invalidated the user’s cached permissions, so access could never drift out of sync with what they’d actually paid for.

Takeaways

This project was a reminder that a small, focused team can ship genuinely enterprise-grade software by pairing a high-performance core (Go) with specialized SaaS where it counts (Stripe for billing, Bunny.net for media). The result cut the clinic’s video-delivery costs while giving users an instant, secure experience — both watching content and talking to a real specialist in real time. And like the best projects, it was better for being built by two people pushing each other.