Building Polloye: architecture, decisions, and the bug that actually taught me something

Polloyo is a Kahoot style live quiz platform I built, host creates a quiz, participants join with a 6 digit code or QR, and everyone competes in real time based on speed and accuracy. It sounds simple on the surface. It wasn't. This post is about the decisions that shaped it, a few tradeoffs I'm still not 100% sure I got right, and one bug that actually forced me to understand something instead of just fixing it.
The basic shape of the system
There are two servers here, a Next.js server for the normal CRUD stuff (auth, quiz creation, editing questions) and a WebSocket server that handles the actual live quiz session. They talk to each other using a shared JWT. Tokens are short lived, around 3 to 4 hours, just long enough to cover a session without becoming a security liability.
The flow when a host starts a quiz looks like this:
Host clicks "start quiz", a REST call hits the Next server, a
QuizSessiongets created with a session code.Host gets redirected to
/session/[id]/host.That page calls a token minting route on load,
/session/[id]/host-token.The frontend uses that token to open a socket.io connection to the WS server, passed in the handshake.
Participants go through a similar flow when they join, they get a token at /join-quiz when they pick a nickname (or when a logged in user joins the waiting room). That token is what lets the WS server identify someone even if they disconnect and come back mid quiz, without needing them to be logged in at all.
Database Design
The schema is built around a Quiz (the template) being separate from a QuizSession (one live instance of running it), which is what makes quiz sharing and re-running a quiz with a fresh session code possible without duplicating the actual questions and options. Participants are tied to a Sessionid, not to a Quiz, since the same person can join the same quiz template across different sessions as a completely separate participant each time. Responses store both option_id as an array and question_id, since MSQ questions need multiple selected options per response while MCQ and true/false only ever have one.
Why two servers instead of one
This was the first real architectural decision, and it wasn't obvious. Next.js can technically handle WebSocket-adjacent stuff, but a live quiz session needs to hold state in memory, active participants, current question, scores in flight, and that doesn't play well with how Next's serverless-leaning model wants to run. Splitting the concerns meant the WS server could own its own memory model cleanly (more on that below) and the Next server could stay stateless and just deal with CRUD and auth. The tradeoff is you now have two servers to deploy, monitor, and keep in sync on auth, which is exactly why the shared JWT approach exists, it's the thinnest possible contract between them.
Loading the quiz into memory
When a host presses "begin", the entire quiz gets bootstrapped into the WS server's memory using a JS map. Every question, every option, all of it, sitting in memory for the duration of that session. Once a question has been passed, it gets dropped from memory to keep things lean, but participant responses for the current question stay in memory until the answer is revealed. Only then does scoring happen and the entry gets written to the DB.
This was a deliberate choice over hitting the DB on every submission. A live quiz session with dozens of participants submitting answers within the same few seconds is a bad time to be doing DB writes on the hot path. Score gets calculated the moment someone submits, but only actually shown to them once the host reveals the answer, and only written to DB at reveal time. In-memory during the pressure, DB once it's actually settled.
The scoring formula, and why linear decay isn't actually dumb
Score decays based on how fast you answer:
pointsEarned = max(0, floor(maxScore * (0.1 + 0.9 * ((duration - elapsed) / duration))))
Duration and elapsed are both in milliseconds, on purpose, so two participants who feel like they submitted at "the same time" still end up with different scores. Slowest correct submission on the buzzer still gets 10% of the question's score, fastest gets close to full marks.
I went back and forth on whether linear decay was too simplistic, an exponential curve or something with sharper early dropoff felt more "correct" at first. But the point of the score isn't really the score, it's the ranking. Someone genuinely fast and correct is still going to consistently beat someone copying off the leaderboard reveal or guessing late, regardless of the exact curve shape. Linear is simple, predictable, and good enough for what it needs to do. Sometimes the simpler formula is the right call precisely because the complexity doesn't buy you anything real.
Handling reconnects (host and participant are different problems)
If the host disconnects mid quiz, the quiz doesn't stop. It runs autonomously, answers reveal automatically 60 seconds after a question appears, next question comes 30 seconds after that, and the leaderboard shows up every 2 questions. Score decay still uses the original question duration even if the host is gone, so nobody gets an unfair advantage just because the host's wifi died. When the host reconnects, their token authenticates them and they pick back up where the quiz autonomously left off.
Participants reconnecting is a different problem entirely. If they drop and come back, their past responses are already recorded, so they just continue on to the upcoming questions. They don't get to retroactively attempt the question that was live while they were disconnected, because letting someone rejoin mid question and answer it would be an unfair advantage over everyone who was there the whole time. Different failure mode, different fix, I didn't want to solve these with one generic "reconnect logic" because the actual fairness constraints are different for each role.
Duplicate submissions and quiz sharing
If a participant somehow sends multiple responses to the same question (bad network causing a retry, or someone trying to game it), only the first one counts. Simple rule, easy to reason about, no ambiguity in an edge case that will happen constantly at scale with dozens of participants on shaky mobile connections.
Quiz sharing was a smaller feature but one I think matters a lot for actual usage, a quiz has a sharing code separate from its live session code. That sharing code lets someone else clone the quiz structure and run their own live session off it. This came directly from thinking about actual use cases, a teacher building a quiz and other teachers running the same one in their own sections, without needing to rebuild it or share a login.
The bug that actually taught me something
Most of what I've described above I designed up front and it mostly worked as planned. The one that didn't was smaller and dumber, and it's the one I actually learned from.
Symptom, when I log in, sometimes it silently fails the first try, bounces me back to the login screen, and the second attempt works fine. Clearing site data made it work first try, every time.
Here's what's actually happening, signIn.email() resolves and sets the session cookie, but router.push("/dashboard") was firing before useSession() had re-fetched and reflected that new cookie. The dashboard mounts, reads a stale null session, and its own effect immediately bounces you back to login. By the time you're back, the session hook has caught up, and the second login attempt works.
The fix on the login side was to stop pushing to the dashboard immediately after sign in, and instead wait for a useEffect watching the session to confirm !isPending && session before navigating.
What made this actually stick for me wasn't the fix itself, it was a second, related bug on the dashboard. If you clear cookies while already logged in and sitting on the dashboard, the page got stuck on a loading spinner indefinitely. I checked the network tab and found something that surprised me, six separate get-session calls firing back to back, all returning 200 with a null session body. Not a hang, not an error, just repeated identical checks.
The actual fix ended up being a flag the auth library exposes, isRefetching, alongside isPending. The redirect logic needed to wait for both to settle, not just isPending, before deciding there's no session. Waiting on isPending alone meant catching the hook mid background-refetch and reacting to a stale intermediate null, exactly the same shape of bug as the login race, just on the other side of the session lifecycle.
What I actually took away from this one wasn't the fix, it was the discipline underneath it. It's very easy to accept a plausible sounding explanation for why something's broken and move on once the symptom disappears. The only thing that actually told me what was going on was going back to the network tab and looking at the real requests and responses, not reasoning about what should be happening in theory.
What I'd do differently
If I rebuilt the auth flow today, I'd probably centralize this session-ready check into one hook used everywhere instead of handling it per page, login and dashboard both ended up independently reimplementing "wait until the session is actually settled" logic, which is exactly the kind of duplication that lets bugs like this hide in the seams between two places that were each individually reasoned about but never checked against each other.



