Engineering · Mar 5, 2026
Olimpijski Kviz: a live TV quiz without WebSockets
How HTMX polling, a Redis cache filled ahead of time and a one-second timer kept every phone in step with the broadcast
By Toni · 14 min read
When your app runs live on national television, there's no second chance. Olimpijski Kviz was a live TV quiz that ran alongside RTV Slovenija's coverage of the Milano-Cortina Winter Olympics, and every one of its 16 episodes was a one-shot event.
This is our second blog post, published two days after the first, and the timing is on purpose. In our first post we introduced ourselves through GZS Inovacije, the platform we built for the Chamber of Commerce and Industry of Slovenia to run its national innovation awards across 13 regional chambers.
The two projects could hardly be more different. One is an admin platform for structured judging. The other is a live event platform built for 10,000 concurrent players, where any mistake happens in front of the whole audience.
What they share is the approach, with the same stack and the same way of making decisions. JT Digital is less than two months old, and that workflow already reaches from a quiet admin panel to live television.
A live TV quiz for the Winter Olympics
Olimpijski Kviz 2026 ("Olympic Quiz") ran during the Winter Olympics coverage on TV Slovenia 2, a channel of RTV Slovenija, Slovenia's national broadcaster. We built it for Sport Media Focus, and the Olimpijski Kviz case study has the short version.
During the broadcast a QR code appeared on screen. Viewers scanned it with their phones, opened the quiz site and played along live. Each quiz had 16 questions, four answers to choose from and 20 seconds to answer. Scoring worked like Kahoot, the classroom quiz game, so a faster correct answer earned more points.
The competition ran for 16 episodes, one a day from 7 to 22 February 2026. A player's best 10 results counted toward the final ranking, so a place in the final standings took at least 10 quizzes.
The first commit went in on 19 December 2025, and the first quiz aired on 7 February, seven weeks later. The build that passed the load tests was running four weeks in.
The lobby. Players arrive from the QR code, see how many others have joined and watch the countdown to the first question.
From the QR code on screen to the final standings, with every phone asking the server for its screen once a second.
Why live television changes the engineering
Every constraint on this project shaped the design, and each one made the others harder.
Live TV. There is zero tolerance for errors, because nothing can be run again. Nobody gets to say "we'll fix it in tomorrow's release". If the quiz breaks during a broadcast, it breaks in front of the audience.
Built for 10,000 concurrent players. Every architectural choice had to survive a thundering herd, the moment thousands of users arrive within seconds of each other and ask for the same data. Database connections, cache refreshes and connection pooling all had to hold under that load.
Sub-second responses. A tap on an answer has to feel instant. A player who waits more than a couple of seconds stops trusting the quiz.
A date that could not move. The Games start when they start, so there was no time for exploratory prototypes or a rewrite. The stack and the architecture had to be right from the first week.
Synchronization. Every phone had to show the same question at the same time, within one to three seconds of the others.
Sponsor videos. Sponsors run 20-second videos in the middle of the quiz. At the 10,000 players the system was built for, that means 10,000 phones starting the same video at once, and that needs infrastructure of its own.
The same stack on a bigger stage
Olimpijski Kviz runs on the same stack as GZS Inovacije and everything else we build: Fastify v5, TypeScript, HTMX and fluent-html, our typed HTML builder. The server renders the HTML, and there is no client-side framework.
We did not reach for a real-time framework or for WebSockets, the connections a server holds open to every client. Every phone asks for fresh server-rendered HTML once a second through HTMX, a small library that swaps that HTML into the page, and that is the whole synchronization mechanism.
What changed for this scale was the infrastructure around the stack:
- PostgreSQL 16 with Prisma, the database library, and PgBouncer pooling 380 connections
- Redis 7, an in-memory store, for the cache, the locks and the answer counters
- BullMQ job queues for leaderboard computation and emails
- PM2 running the Node.js processes across two servers
- Prometheus, Grafana and Loki for metrics, dashboards and logs, with 28 custom metrics
- Cloudflare R2 and its CDN to deliver sponsor videos to every phone at once
The production setup. Two servers run 16 PM2 processes, with PostgreSQL behind PgBouncer, Redis, Cloudflare for video, and Prometheus and Grafana for monitoring.
Inside the quiz engine
Infrastructure on DigitalOcean and Cloudflare
Production ran on two DigitalOcean AMD servers with 8 vCPUs and 16 GB of RAM each. Each server ran 7 web processes and 1 dedicated worker under PM2, 16 processes in total. A third server served as a backup and a load-testing target, and the whole setup was built for 10,000 concurrent players.
The database was a managed PostgreSQL instance with 4 vCPUs and 16 GB of RAM. In front of it, PgBouncer (a connection pooler) held 380 connections and shared them among the 16 Prisma clients, each allowed up to 23. A managed Redis with 4 GB of memory held the cache, the locks and the atomic counters.
The video problem. Sponsor expositions are 20-second clips shown mid-quiz. When the state machine, the code that moves each quiz from phase to phase, enters the sponsor phase (SPONSOR_EXPOSITION), every phone in the quiz requests the same video file in the same second. Without a CDN, that is thousands of video streams leaving one origin at the same moment.
The videos live in Cloudflare R2, file storage that works with the same tools as Amazon S3, and Cloudflare's CDN delivers them. A CDN keeps copies of a file on servers close to the people asking for it. Only the first request at each location reaches storage, and every later one is served from the nearby copy.
Deployment runs through GitLab CI/CD, GitLab's automated pipeline. A push to main deploys to one server and then the other, with the same steps on each: pull, install, generate the Prisma client, compile TypeScript, run migrations and reload PM2 with zero downtime. Health checks afterwards confirm the PM2 process count, the HTTP endpoint, memory and disk space.
A state machine with six phases
Every quiz moves through six phases, and the state machine allows only the moves listed in its transition table.
A one-second timer moves each quiz through its phases, a Redis lock stops two processes moving the same quiz, and a sponsor video can take a question's slot.
Nobody has to press a button during the broadcast. A dedicated worker process runs a one-second heartbeat with setInterval. On every tick it checks each active quiz, and if the current phase has run out of time, it triggers the transition.
Why setInterval over a job queue like BullMQ? We tried BullMQ first. It had 10 to 15 second delays in edge cases, unacceptable for live TV. setInterval heals itself, because a missed tick is caught by the next one, and there is no job lifecycle or cancellation logic to manage.
BullMQ drove the loop for the first nine broadcasts. We moved it to the timer on 16 February, with seven broadcasts still to go, and gave the timer its own PM2 process on each server so it never waits behind web requests.
Distributed locking keeps two of the 16 processes from changing the same quiz at the same moment. Every state transition takes an exclusive Redis lock before it reads or writes quiz state. Without it, two processes could both read "phase = QUESTION, time is up" and both try to move the quiz on, corrupting its state.
// Acquire: atomic SET with NX (only-if-not-exists) + 5s hard expiry
await redis.set(lockKey, lockId, 'EX', 5, 'NX');
// Release: Lua script verifies ownership atomically
// Only the process that acquired the lock can release it
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(luaScript, 1, lockKey, lockId);
A Redis lock taken in one atomic step and released only by the process that holds it
Each transition follows a strict sequence:
- Take the lock.
- In one database transaction, check the current state, check that the move is allowed, write an audit record and update the quiz.
- After the transaction, write the new state to the cache straight away, reset the answer counters and log any slow transaction.
- Release the lock in a
finallyblock, so it is released even when something fails.
When a quiz completes, one SQL statement ranks every attempt.
UPDATE "QuizAttempt"
SET "rank" = ranked.calculated_rank,
"isCompleted" = true,
"completedAt" = NOW()
FROM (
SELECT id, RANK() OVER (
ORDER BY "totalScore" DESC, "totalTime" ASC
) as calculated_rank
FROM "QuizAttempt"
WHERE "quizId" = $1
) as ranked
WHERE "QuizAttempt".id = ranked.id
AND "QuizAttempt"."quizId" = $1
Ranking every attempt in one SQL statement with RANK()
No application code reads ranks and writes them back, so two players finishing at the same moment cannot corrupt each other's rank. There are no loops and no N+1 queries, the pattern where code runs one query per row.
Caching before anyone asks
With 10,000 players polling once a second, the server takes 10,000 requests a second. If even 1% of them missed the cache and went to PostgreSQL, the database would see 100 identical queries a second, and at that scale it falls over.
Our answer was a worker that pushes the hot data into Redis before anyone asks for it. Every second it reads the state of each active quiz from the database and writes it to Redis. On every phase change the state machine also writes the new state straight away. A phone that polls in the moment before that write lands gets an empty reply (HTTP 204) and keeps its current screen until the next poll.
The biggest single win was on the hot path, the code every poll runs. Prisma's include runs an extra query for each related table, so we replaced it with one hand-written SQL query that joins questions and answers, and grouped the rows into nested objects in code. That query dropped from 252 ms to 30 to 50 ms under load. Earlier N+1 fixes had already taken about 200 database queries a second off the question and standings phases.
The "has everyone answered?" check used to run SELECT COUNT(*), and one documented bug saw more than 390 of those queries in two seconds. It now reads a Redis counter that each answer increments, so the check costs the same for 100 players or 10,000.
HTMX polling with idiomorph
One endpoint, six views. GET /quiz/:quizId/play returns the right HTML for the quiz's current phase. HTMX polls it every second with trigger: "every 1000ms" and swaps with morph:outerHTML. That swap uses idiomorph, which compares the new HTML with the page and changes only what differs.
A plain swap that replaces the whole element would stop any video mid-play, reset the scroll position, take focus away from inputs and flicker between phases. With the morph, sponsor videos keep playing across polls and phase changes look smooth.
Why no WebSockets. With one-second polling a phone sees a new phase within about a second, inside the one to three second sync budget. Server-rendered HTML with polling is far simpler than keeping WebSocket state in step across 16 processes. Polling needs no client-side state to synchronize, no reconnection logic and no message ordering. For a quiz, "real-time enough" was the right target.
Monitoring during the broadcast
Prometheus collects 28 custom metrics from the app, and Grafana draws them on three dashboards: app performance, infrastructure health and live quiz operations. Loki gathers the logs alongside them.
During every live broadcast the dashboards were open on a second screen. A problem shows up on a graph before players notice it.
Load testing before the first broadcast
We used k6, an open-source load-testing tool, to drive virtual users (simulated players) through the real quiz flow. The first rounds ran on a laptop, a MacBook Pro M1 with 8 PM2 processes and PostgreSQL and Redis in Docker. The later rounds ran on DigitalOcean behind its load balancer, with managed PostgreSQL and Redis over the network.
The table gives the median response time and the P95, the time within which 95% of requests were answered.
| Run | Virtual users | Req/sec | Median | P95 | Failed | Notes |
|---|---|---|---|---|---|---|
| Laptop, 12 to 13 Jan | 2,000 | 3,479 | 16ms | 118ms | 0% | 5,011,155 requests |
| Laptop, 16 Jan | 4,000 | 2,959 | 109ms | 696ms | 0% | 1,066,605 requests, after the N+1 fixes |
| DigitalOcean, 18 Jan | 15,000 | 2,055 | 283ms | 1,408ms | 0.50% | Best run through the load balancer |
| DigitalOcean, 20 Jan | 20,000 | 5,122 | 1,556ms | 3,204ms | 0.58% | Found the TLS and response-time bottleneck |
On the laptop the system went past a million requests with no failures. The 16 January run sent 1,066,605 requests from 4,000 virtual users, held 2,959 requests a second at a median of 109 ms, and answered 98.4% of them within a second.
The cloud runs found what a laptop hides. At 20,000 virtual users, TLS handshakes (setting up each encrypted connection) averaged 325 ms, and the server took 1,537 ms on average to send its first byte. Each request makes 2 to 5 calls to Redis or the database, about 2 to 5 ms each over the network against about 0.1 ms on the laptop, and those round trips add up.
What the load tests changed:
- All connections moved to the private network inside the data centre (a VPC), which saved about 40 ms on every new database connection.
- The N+1 fixes removed about 200 database queries a second during the question and standings phases.
- Redis counters replaced the expensive COUNT queries.
- A countdown on the phone, synced to the server's clock, made fairness independent of how long each poll takes.
- The polling interval went from 500 ms to 1,000 ms, which halved the request load, and the phone's own countdown kept the screen smooth.
- The state machine began filling the cache the moment a phase changed, which closed a staleness window that sent a thundering herd to the database at every transition.
Every serious bug the January load tests found also became a permanent test, in a folder called critical-bugs. Those tests cover the cache staleness race, polling errors, the N+1 patterns, the sponsor phase cache and synchronization.
How scoring works
A correct answer earns between 50 and 100 points depending on speed. Answer instantly and you get the full 100, answer at the last moment and you still get 50, and a wrong answer gets zero. The server times every answer from its own clock, so a phone cannot claim it answered faster than it did.
function calculatePointsForAnswer(
isCorrect: boolean,
timeSpent: number,
timeLimit: number,
maxPoints: number = 100
): number {
if (!isCorrect) return 0;
const normalizedTime = Math.max(0, Math.min(timeSpent, timeLimit));
const multiplier = 1 - (normalizedTime / timeLimit) / 2;
return Math.round(multiplier * maxPoints);
}
// Instant (0s) → 1.0 × 100 = 100 points
// Quarter time → 0.875 × 100 = 88 points
// Half time → 0.75 × 100 = 75 points
// At time limit → 0.5 × 100 = 50 points
Time-based scoring, where a faster correct answer earns more points
What we learned from 16 live broadcasts
- The same stack scales. Fastify, HTMX and fluent-html carry an admin platform for judging (GZS Inovacije) and a live TV quiz built for 10,000 concurrent players. The infrastructure around them changes, and the code patterns stay the same. The page on how we ship on proven parts explains the approach.
- setInterval is underrated. BullMQ had 10 to 15 second delays in edge cases. A plain one-second setInterval with a Redis lock took over the quiz loop on 16 February and ran the last seven broadcasts.
- Fill the cache before anyone asks. If you know what every phone will request next, put it in Redis before the phase starts. The per-quiz leaderboard costs a handful of queries per question round, the same for 100 players or 10,000, and every phone reads the cached result.
- Monitoring during live events is non-negotiable. The Grafana dashboards were working tools on every broadcast night, and they show a problem before players feel it.
- Synchronized video needs a CDN. Thousands of phones pressing play on the same file in the same second is an engineering problem of its own. Cloudflare's CDN served those copies from servers close to each phone.
- AI-assisted development let us move faster without moving recklessly. Seven weeks from first commit to air, with this much testing, would have been very tight for one team without Claude Code, an AI coding assistant. It multiplies experienced engineering, and the tests kept the speed safe. We later wrote up the workflow we use with Claude Code.
How the 16 broadcasts went
16 episodes, zero downtime, zero critical errors. 3,727 people registered during the competition, and 2,365 of them played at least one of the 16 quizzes, for 4,996 quiz attempts in total.
Players submitted 65,409 answers and got 41.6% of them right, so the questions were genuinely hard. The final episode drew the most players, 544, and the quietest evening drew 154.
The top player, GibčniŠportnik_4004, scored 16,047 points across all 16 quizzes, more than 1,000 a night. Public boards only ever show nicknames the server generates. Two sponsors ran video expositions inside the quizzes, Športna loterija in 7 episodes and Ford in 5.
The platform was built for 10,000 concurrent players and load-tested past a million requests. The real audience was smaller. We built for the worst case anyway, because nobody can know in advance how many people will scan a QR code on national television.
Talk to us about your live event
If you are planning a live event platform, a high-traffic app or any system that cannot go down on the night, we have run one on national television. Book a 15-minute call with Toni and Jakob. There is nothing to prepare and nothing to sign.
Co-founder
More in this vein
Our Claude Code Workflow: How We Ship Software
Our Claude Code workflow rests on plans kept in git, rules the agent reads first, a typed stack that rejects mistakes and in-house tools that feed each other.
14 min read · Toni
Type-Safe HTML in TypeScript: Wrong Markup Won't Compile
Type-safe HTML in TypeScript turns a misspelled route, a wrong HTMX target or an impossible view state into a compile error. This is how fluent-html does it.
10 min read · Toni
Stay in the loop
Don't miss the next one.
One email when we publish something worth your time.