All work

Queued

A shared music playlist where everyone votes, and the order updates for the whole room at once.

Year
2024
Stack
Node · Socket.IO · PostgreSQL · Redis · Docker
Code
01 Why

Whoever holds the aux holds the room.

Every shared space has the same small problem. One person controls the music, everyone else has opinions, and there is no fair way to resolve them. I wanted a playlist that belonged to the room instead of to a person: anyone can add a track, anyone can vote, and the queue reorders itself based on what the group actually wants.

The hard requirement was that it had to feel live. If you vote and the order does not visibly shift for a beat, the whole thing feels broken. So the real target was not a feature list. It was latency.

This was my first real-time project. I had built plenty of request-response APIs. A system where the server pushes to clients, and clients have to agree on shared state, is a different shape of problem.

02 What I built

A voting queue that broadcasts itself.

Queued is a Node backend with Socket.IO for the real-time layer, PostgreSQL as the source of truth, and Redis as a cache in front of it. Tracks are searched and added through the YouTube Data API. Each track in the playlist carries a vote count, and the playlist is ordered as a priority queue keyed on that count.

When someone votes, the server updates the count, recomputes the order, and pushes the new playlist to every client in that room over Socket.IO. There is no polling and no manual refresh. The client that voted and the five clients that did not all see the same update at the same time.

// A vote updates state, then broadcasts the new order to the room socket.on('vote', async ({ roomId, trackId }) => { await recordVote(roomId, trackId); const playlist = await getOrderedPlaylist(roomId); io.to(roomId).emit('playlist:update', playlist); });

On a local test with around 50 concurrent clients, a vote propagated to every client in roughly 80 milliseconds. That is the number that decides whether the room feels alive, and 80ms is comfortably under the threshold where a delay becomes noticeable.

03 The interesting part

Redis took two thirds of the read load.

The first version read the playlist straight from PostgreSQL on every vote and every page load. That works until a busy room. The playlist for an active room gets read constantly, far more often than it changes, and every one of those reads was hitting the database.

So the ordered playlist for each room is cached in Redis. Reads hit Redis first. A write, which means a vote, updates Postgres and then invalidates the cache so the next read rebuilds it from fresh data.

The cache is keyed per room. A vote in one room never invalidates another room's playlist, so busy rooms and quiet rooms do not interfere with each other.

The same cache helped a second, less obvious problem. The YouTube Data API has a daily quota. Caching track metadata instead of refetching it on every render roughly halved the quota consumption, which is the difference between the app working all day and the app dying at 4pm.

PostgreSQL read load after caching ~65% reduction
YouTube API quota consumption roughly halved
Vote propagation, ~50 clients (local) ~80 ms
k6 load test, concurrent votes (local) ~500 req/s, no lost votes
04 Correctness

Concurrent votes cannot step on each other.

A shared playlist is a shared counter, and shared counters have the classic race. Two people vote for the same track at the same moment. Both read a count of 7, both write 8, and one vote silently vanishes.

Vote writes run as ACID transactions in PostgreSQL, with the count updated atomically rather than read into the application and written back. To prove it actually held up, I load-tested the vote endpoint with k6, firing concurrent votes at around 500 requests per second on a local setup. The recorded vote totals matched the number of requests sent. Nothing was lost under contention.

These are local numbers, not production traffic. The point of the k6 run was not to publish a benchmark. It was to catch the lost-vote bug before a real room of people did.

-- Atomic increment, no read-modify-write gap in app code UPDATE tracks SET votes = votes + 1 WHERE id = $trackId AND room_id = $roomId;

Doing the increment inside the database, in one statement, means there is no window between reading and writing for another transaction to slip into. The YouTube integration uses OAuth 2.0 so that track search runs against the API without the client ever seeing server credentials.

05 What I learned

Real-time is a caching problem wearing a costume.

I expected the hard part of Queued to be Socket.IO. It was not. Pushing events to a room is straightforward once it clicks. The hard part was everything underneath: making sure the state being pushed was correct, and making sure reading that state did not melt the database.

The lesson that stuck: real-time does not mean recompute everything on every event. It means know exactly what changed, update only that, cache the rest, and broadcast the smallest update you can. The latency target was met by caching aggressively, not by pushing harder.

If I rebuilt it today I would add reconnection handling for clients whose tab slept, and an event log so a client coming back can catch up cleanly instead of refetching the whole playlist. That gap is the subject of one of the posts on my writing page.

Next project
Chess Engine