Hey everyone!

I'm a 20-year-old self-taught developer from Thailand preparing to break into the industry as a junior full-stack/backend dev.

I recently built an open-source group video conferencing and real-time chat app called Chatn. When I started, I thought I could just follow a basic WebRTC tutorial. But as soon as I tried scaling beyond 2 users on localhost, things broke down fast.

Here are the main technical hurdles I ran into and how I solved them:

  1. The P2P WebRTC Bottleneck (Switching to LiveKit SFU)

My first prototype used a standard WebRTC P2P mesh. It worked fine between 2 browser tabs. But with 4–5 participants, my laptop fan went crazy and video froze.

In P2P mesh, connections scale as O(N²). For 5 participants, every client has to encode and upload their video stream to 4 other peers (O(N) upload bandwidth). On typical home Wi-Fi with low upload bandwidth, the uplink gets choked immediately.

The Fix: I switched to an SFU (Selective Forwarding Unit) using LiveKit. With an SFU, each client encodes and uploads only 1 stream to the server (O(1) upload). The server then forwards the video packets to the other peers. Client CPU usage dropped, and video stayed smooth even with multiple users.

  1. Separating Video from Persistent Chat (Laravel Reverb)

LiveKit has built-in data channels for text messaging, but WebRTC data channels are ephemeral. If someone joins the meeting 10 minutes late, they won't see any previous messages.

I wanted real message persistence, authorization policies, and chat history. So I separated concerns:

LiveKit: Dedicated strictly to heavy UDP video/audio packet routing. Laravel Reverb (WebSockets) + MySQL: Handles real-time chat, authentication, DB transactions, and presence events. 
  1. The "Shifting Window" Bug (Cursor Pagination)

For chat history, I initially used traditional offset pagination (LIMIT 20 OFFSET 20).

In an active room, if someone is scrolling up through old messages while 5 new messages are being sent, the offset shifts. The user ends up seeing duplicate messages or skipping messages entirely.

The Fix: Switched to keyset / cursor pagination (WHERE id < :last_seen_id ORDER BY id DESC LIMIT 20). Because the query is anchored to a specific message ID, new incoming messages at the top never alter the pagination window. It also does an instant primary key B-Tree index seek instead of scanning and discarding thousands of rows in MySQL.

  1. Testing WebRTC on Mobile over LAN (Docker + Caddy + nip.io)

When I tried testing video calls between my laptop and physical phone on my Wi-Fi (http://192.168.1.x), mobile browsers immediately blocked getUserMedia(). Browsers require a Secure Context (HTTPS) for camera and microphone permissions.

The Fix:

Containerized the entire stack with Docker Compose. Put Caddy in front as a reverse proxy. Used nip.io wildcard DNS (https://192-168-1-50.nip.io). Caddy automatically generates and trusts local SSL certificates, allowing my phone to access the app over HTTPS on the local network with zero certificate friction. 

Tech Stack

Backend API: PHP 8.2, Laravel 11, Laravel Reverb (WebSockets), MySQL Media Server: LiveKit (SFU) Frontend: Next.js 14 (App Router), TypeScript, Tailwind CSS, TanStack Query, Zustand DevOps: Docker, Docker Compose, Caddy Reverse Proxy 

GitHub Repositories (Open Source)

Orchestration: https://github.com/nayhtetkhant/chatn-main Backend API: https://github.com/nayhtetkhant/chatn-api Frontend: https://github.com/nayhtetkhant/chatn-web Portfolio: https://nayhtetkhant.com/ 

I would love to get your thoughts, constructive feedback, or any potential architectural bottlenecks you spot in the repos!