Real-time is a notification layer. The database is still the truth.
I built the Lesson Whiteboard for an e-learning platform with 20,000+ students. A tutor marks up lesson material during a live class, and every enrolled student sees the marks arrive on their own screen. It runs on a Laravel API, Laravel Reverb for WebSockets, a React tutor app, and a React student app.
The drawing was the easy part. The hard part was deciding what the socket is allowed to do. These are the five rules I ended up with.
1. Authorise the channel in one place
Every lesson has a private channel, private-event.{id}.whiteboard. One class decides who can join it: the assigned tutor and the enrolled students. Everyone else is refused, including admins and a student whose enrolment was soft-deleted because they rescheduled out.
The same class also refuses channels for sessions that have no board, such as mock tests, writing-correction sessions, and meetings. It checks against the same list of event types the REST endpoints use. So a typed URL or a stale tab can't subscribe to a board that doesn't exist.
This is the rule the tests pin down hardest: tutor and student get 200, an outsider gets 403, a soft-deleted enrolment gets 403, and an anonymous request gets 401.
2. Write first. Broadcast after. Never let the broadcast fail the write.
The order matters. The service saves the annotation, and only then dispatches the event. If the save is refused, nothing is broadcast.
The broadcast itself is wrapped so it can't change the outcome of the request:
// Simplified
protected function broadcastSafely(callable $dispatch): void
{
try {
$dispatch();
} catch (\Throwable $e) {
Log::warning('whiteboard.broadcast_failed', ['error' => $e->getMessage()]);
}
}
If Reverb is down, has bad credentials, or refuses the connection, the tutor's mark is still saved and the request still returns 2xx. The student's board catches up through the fallback in rule 5. A real-time outage becomes a slower board, not lost work.
3. Broadcast a pointer, not the payload
The channel is whole-class: every enrolled student is subscribed. A correction ("you said I have went; say I went") belongs to one student.
So the corrections event carries only a pointer: {event_id, correction_id, target_user_id, action}. Never the text. When a client receives it, it re-reads its own scoped view through the API. The tutor re-reads their full list. The student re-reads only their own corrections and the class counts.
This costs one extra request per change. In exchange, the channel can't leak one student's data to another, no matter who is listening. The API's existing authorization does the scoping, so the socket never has to.
4. Anchor geometry to the page, and draw only once the page exists
A mark is stored as a rectangle in page percentages, not screen pixels. The tutor's laptop, a student's phone, and the recap after class all render the same mark in the same place, at whatever size the page is shown.
That exposed a timing bug. The overlay painted marks as soon as the page was requested. On a heavy PDF the tutor's ink floated over blank white for seconds, because a mark positioned as "30% down the page" is meaningless without a page. The fix: the drawing layer waits for react-pdf's onRenderSuccess, keyed by material and page so a page turn re-arms it. A regression test holds the page in its unrendered state and asserts nothing is drawn.
5. Degrade on purpose, and stop when it's over
If the socket isn't live, the student board polls every 10 seconds, so a lesson on bad Wi-Fi still works.
The first version had a bug: the recap view never opens the channel, so it was permanently "not live", and it kept polling long after class ended. The interval now checks, from the query's own data, that the lesson is still running. Degraded mode is a fallback for live lessons, not a background job.
The shape underneath
Put together, the design is simple:
- The REST API is the source of truth and the only place data gets scoped.
- The channel is authorised once and only says "something changed, here's the ID".
- Clients re-read what they're allowed to see, and fall back to polling when the socket drops.
Most of the real-time bugs I fixed were places where the socket had been trusted to do more than that.

Henry Iddirisu
AI product engineer · Accra, Ghana · Remote