←All insights
Realtime · Postgres7 min readOct 2026

Keeping a watch party in sync with one Postgres row

How Screening Rooms keep guests in different houses on the same scene with HTTP polling, a server clock and a 1.5 second tolerance.

Shazflix is a private streaming app for my family, and the feature I most wanted to get right was watching together from different houses. One person presses play, and everyone's film starts. Pause, and every screen pauses. The obvious answer is a realtime service with websockets. I didn't want another paid dependency for something a handful of people use a few evenings a month, and the app already runs on Vercel with Neon Postgres. So Screening Rooms run on plain HTTP and one row per room.

One writer, many readers

The rule that made everything else tractable is that a room has exactly one writer. The host controls playback. Guests can chat, react and pin moments, but cannot play, pause or seek. That removes the hardest part of shared playback, merging conflicting commands, and turns the problem into "publish the host's state, and have everyone else follow it".

The state lives on the rooms row: besides the join code, film and host, it holds the position in whole seconds, the duration, whether it is playing, the rate, updated_at, and a closed_at for a room the host has ended.

room_members has a composite primary key of room and profile plus a last_seen_at column, which is the whole presence system. room_messages holds chat, reactions and pinned moments, keyed by a bigserial id so clients can ask for "everything after id N".

Who writes what
Writers
Hostplay, pause, seek
Every pollheartbeat
Any memberchat, pins
Endpoints
PUT statehost only
GET stateevery 2s
POST messages20 per minute
Tables
roomsplayback state
room_memberslast_seen_at
room_messagesbigserial id

The important column is updated_at. Position alone is stale the moment it's written; position plus the time it was true tells you where the film is now.

How a room stays in sync
Host browser
Video eventsplay, pause, seek
PUT stateevery 5s + on change
Server
Route handlerZod-validated
Host checkonly writer
Data
rooms rowPostgres · Neon
room_memberspresence
Guest browsers
GET stateevery 2s
Follow planseek if over 1.5s

The host publishes

On the host's device, the hook pushes the video element's state every 5 seconds, so quiet stretches stay current, and 350 ms after any of these media events: play, pause, seeked, ratechange, ended, waiting and playing. Listening to the element rather than my own buttons means keyboard shortcuts, the scrubber and swipe gestures are covered by one listener. waiting and playing came later. They bracket a stall, so when the host buffers, guests don't run ahead of the person in charge.

The route checks the request's origin and validates the payload with Zod: position and duration capped at 24 hours, rate between 0.25 and 2. The write then refuses anyone but the host of an open room, rounds position and duration down to whole seconds, and stamps updated_at with the server's clock. The browser never decides when a position was true, which matters once guests do arithmetic with it.

Life of a host push
Trigger
Media event350 ms debounce
Interval tickevery 5s
Client gate
Push flagown in-flight flag
Pending slotqueue of one
Server
Zod and originshape, limits
Host checkopen room only
Row
rooms updateserver-stamped updated_at

One early bug was subtle. Polls and pushes shared a single "request in flight" flag. If a poll was mid-flight when the host pressed pause, the push returned early, and the pause didn't reach the server until the next 5 second tick. The fix gave each its own flag plus a queue of one: a push arriving while another is in flight is marked pending and sent the moment the first lands.

Guests poll and extrapolate

Every member, host included, polls every 2 seconds, passing the id of the last message it has seen. The response carries the playback state, the member list, up to 30 new messages, and the server's time in milliseconds. The same request upserts the caller's last_seen_at, so polling is the presence heartbeat too.

The guest's clock can't be trusted to match the server's, so each poll records the skew and corrects for it. With a server-relative "now", the target is arithmetic: paused, it's the stored position; playing, it's the stored position plus the seconds since updated_at, never negative, capped at the duration when known.

From the target, the guest builds a follow plan. Wrong play state: play or pause. Playing at a different rate: change speed. More than 1.5 seconds off and not buffering: seek. Otherwise the plan is empty and nothing happens.

Key idea

Guests never receive commands, only a fact and a timestamp. Each one works out for itself what it should be doing.

That framing handles the awkward cases without special code:

  • ›Pause. A paused target doesn't move, so every guest pauses and a drifted one is seeked to the exact frame.
  • ›Seek. The host's seeked event pushes the new position, and guests see drift far above 1.5 seconds and jump.
  • ›Joining late. A guest who arrives an hour in is a guest with an hour of drift. The first poll seeks them to where everyone else is.
  • ›Playback speed. Rate is part of the state, so guests follow speed as well as position.

Below 1.5 seconds I leave the guest alone. Seeking a video element causes a visible stall, and a guest that seeks every two seconds to fix 300 ms looks far worse than one slightly behind.

Buffering guests

The second fix from real use: a guest on a slow connection would fall behind, be told to seek, start loading, and by the next poll the room had moved on. So they seeked again, restarting the load, and never caught up.

Now the hook passes a buffering flag, true when the element is mid-seek or its readyState is below HAVE_FUTURE_DATA (3). A buffering guest still follows play and pause, but gets no new seek until it has loaded what it asked for. Once it can play, the next poll corrects the remaining drift in one jump.

When the host vanishes

If the host closes the laptop lid, nobody pushes any more, but the row still says "playing from X at time T", and guests would extrapolate for ever. So the server checks the host's last_seen_at when building the poll response. If the room is playing and the host hasn't polled in 20 seconds, it's reported as paused where the host's film had reached by the later of the last push and the last poll.

From row to guest action
Server view
Stored staterooms row
Host presence20s window
Poll reply
Room statepaused if host gone
Server timemilliseconds
Guest maths
Skew correctionlocal vs server
Target positioncapped at duration
Follow plan
Play or pause
Match rate
Seekover 1.5s, not buffering

Nothing is written here. It's a read-time view, so if the host comes back and pushes, the room carries on. The same 20 second window drives the member list. When the host deliberately leaves, closed_at is set, polls report the room closed, and every guest's player pauses on an ended screen.

Join codes and chat

Rooms are joined by an 8 character code, displayed as two groups of four. The alphabet leaves out I, L, O, 0 and 1 because they are easy to misread when someone copies a code off another screen. Codes come from crypto.getRandomValues, and instead of checking first, creation inserts with ON CONFLICT DO NOTHING against a unique index. If nothing comes back the code was taken, so it tries a new one, up to five times. The host becomes a member of their own room.

Joining checks the room is open, the film is still published and the profile's maturity setting allows the film. A child's profile can't follow a code into a film it couldn't open on its own.

Chat, reactions and pins go through one messages endpoint, limited to 20 per minute per profile, and arrive on the same 2 second poll. Up to two seconds of delay is fine for chat during a film.

The trade-offs

Latency. A host action reaches guests after the 350 ms debounce, the push, and up to 2 seconds until the next poll: 0.5 to 2.5 seconds. Nobody notices for a pause. A shared jump scare isn't frame-perfect, and was never meant to be.

Accuracy. The skew calculation ignores network latency, and updated_at marks when the server got the push, not when the host's video was there. Both errors are a few hundred milliseconds, hence 1.5 seconds and not 0.2.

Cost. Each poll is a handful of small indexed queries: a membership check, the presence upsert, then the room, members and new messages in parallel. Five people in a room is two and a half requests a second for the length of a film. Nothing at family scale, a real cost at public scale.

Why not websockets. Vercel functions are request-scoped, so sockets would mean a separate service or a paid realtime product, plus a second source of truth beside Postgres. Polling the row I already authorise keeps one data path, one permission check, and nothing extra to deploy.

What I'd do differently

  • ›Store position as a float. Rounding down on write throws away up to a second, which eats into the 1.5 second tolerance for no reason.
  • ›Measure round-trip time on each poll and use half of it to correct the skew, instead of treating the response as instant.
  • ›Drive the follow logic directly from the poll. At the moment the effect that applies the plan re-runs because the poll replaces the member and chat state, which works but hides the real trigger.
  • ›Give pins their own columns. They are encoded as seconds and a note joined by a colon in the message body, with the seconds duplicated into the emoji column. It avoided a migration, but I end up explaining it in every code review.
  • ›Slow polling when the room is paused or the tab is hidden. A paused room doesn't need a 2 second heartbeat, and long breaks would cost far less.
Available for new workBuilding something like this?I'm open to full-time roles and freelance work, remote or relocation.
↑