SIFO: Building a Live Meeting and Voting Platform for a National Pharmacy Association
An engineering preview of the video meeting platform behind. WebRTC and WebSockets working together, permission-gated video at scale for thousands of attendees, adaptive audio and video processing, a real-time tile layout algorithm, and formal electronic voting for meetings that carry legal weight.
SIFO is Italy's national society for hospital pharmacists, a few thousand members strong, and like most professional associations, a meaningful part of what they do is meetings. Not casual ones either. Congress sessions, board meetings, and formal elections where the outcome needs to hold up if someone questions it later.
They needed a platform to run those online, at a scale a general-purpose video call product isn't really built for. Not a generic meeting embed, a purpose-built tool where joining a session, requesting to speak, and casting a vote all happen inside one coherent system, for an audience that can run into the thousands on a single congress day.
This is the engineering side of that build. Built with Axenso and Lucidly.

#Stack overview
React and TypeScript on Vite, Daily.co for the WebRTC layer, Jotai for client state, TanStack Query for server state, Pusher for the realtime layer that isn't media, Motion for animation, and Mantine for the component layer underneath everything.
Daily specifically because building a WebRTC stack from scratch for a client project is a reliable way to spend months solving problems that are already solved, for a deadline that didn't have months to spare.
#Transport architecture: WebRTC and WebSocket layers
This platform relies on two separate real-time transports, each responsible for a different category of data, and it's worth being explicit about the split.
WebRTC carries audio, video, and screen share. Media is negotiated through STUN and TURN so it can traverse NATs and firewalls, and routed through a selective forwarding unit rather than a true mesh once a room passes a handful of participants. A full mesh means every participant uploads their stream directly to every other participant, which scales poorly. An SFU means each participant uploads once, to the server, and the server forwards streams to whoever needs them. Daily's rooms start as a mesh for very small calls and switch to the SFU automatically past a configurable participant count, since a plain mesh is more efficient for two or three people and genuinely costly past that.
WebSockets carry everything else: presence, chat, hand-raise notifications, permission changes, vote state. That layer runs through Pusher rather than Daily's own signaling channel, deliberately, because votes and chat need to reach a participant regardless of whether their camera is on, their microphone is muted, or their browser tab is backgrounded. Coupling that to the media connection would risk losing a vote notification the moment someone's video hiccups, which is the wrong failure mode for a formal election.
#Audio processing: adaptive noise cancellation
Daily's noise cancellation is a partnership with Krisp, whose AI noise suppression model runs client-side to remove background noise from a participant's microphone input before it leaves their device. It is effective and computationally expensive, and the second property is what shaped the implementation here, though not from theory. It came from production data.
The first version of this feature enabled Krisp unconditionally on join. Sentry was already wired in for error monitoring across the app, and within the first week it surfaced a cluster of reports that didn't look like typical join failures, sessions where CPU-bound errors and dropped frames spiked immediately after the noise cancellation processor activated, clustered around a specific band of lower-powered devices. Cross-referencing the Sentry breadcrumbs against Daily's own overload error made the cause clear: Krisp's model was competing for main thread time on hardware that didn't have the headroom to spare, and the app had no way of knowing that in advance.
That's the actual origin of the pressure sampling logic below, not a defensive guess but a direct response to what Sentry showed happening on real devices. Before attempting to enable it, the app now samples main thread load for roughly one second using the PerformanceObserver longtask entry type. If the sample indicates the device already has limited headroom, or Daily reports a system overload error on activation, the attempt is deferred and retried on an interval rather than forced.
const pressure = await estimateMainThreadPressure(1000);
if (pressure > 0.15) {
return "busy"; // defer, don't attempt yet
}
try {
await setNoiseCancellation(callObject, true);
return "enabled";
} catch (error) {
if (isOverloadError(error)) return "overload"; // retry later
return "failed";
}#Video processing: background blur
The first question worth asking was whether background blur shared any infrastructure with the Krisp integration, since both sit behind the same input settings API and it would have been easy to assume one vendor was responsible for both. That assumption turned out to be wrong. Background blur, background image replacement, and face detection are grouped together as Daily's own video processor family, built in-house, with no third-party model behind them the way Krisp sits behind noise cancellation. Worth stating plainly, since it's a genuinely easy thing to get wrong if you only skim the API surface.
The more useful answer was architectural. Video processors in this family run as a local pipeline stage inserted before the outbound track is published, a segmentation pass evaluated against each frame to separate a participant from their background, followed by a blur applied to everything the segmentation marks as not-participant, at a configurable strength. It runs entirely on-device against the local track, which is why there's no server-side cost tied to how many participants have it turned on, and it's also why only one video processor can be active at a time. Turning on face detection, for instance, implicitly turns off background blur, since they're mutually exclusive stages competing for the same pipeline slot rather than independent toggles.
That last detail is the kind of thing that's easy to hit as a confusing bug in the UI, a user enables one effect and silently loses another, and having it confirmed directly instead of discovered through trial and error saved a real debugging session later. The strength value itself is exposed in the UI as a small set of presets rather than a raw slider, since a continuous control adds configuration surface without adding meaningful value for how this is actually used in a formal meeting context.
#Permission management
The platform needs a controlled "request to speak, host approves, access is granted" flow rather than open media access for a room that can run into the hundreds. Daily's permission model provides the underlying primitives: each participant carries a set of flags describing what they're allowed to send, receive, and administer, and a host can update those flags for any participant in the room.
Two correctness issues surfaced during implementation that are worth documenting, since both are the kind of thing that only appears under real usage rather than in isolated testing.
The first is a double-submission race on the approval action itself. A host approving a request under a degraded connection can trigger the action twice before the first call resolves. The fix wraps the approval in a lock that rejects a second invocation while the first is in flight, releasing the lock a short interval after completion rather than immediately, since the UI update and the underlying round trip to Daily do not resolve within the same render cycle.
The second is a state reconciliation issue. Daily's permission object is the authoritative source of truth, but it updates asynchronously following a host action rather than synchronously with the click that triggered it. Relying on that object directly for UI feedback introduces a visible gap between the host's action and its confirmation, which is a meaningful usability problem on a screen used to run a live meeting. The implementation maintains a local optimistic value scoped specifically to the permission being changed, applied immediately on action and reconciled against the authoritative object once the corresponding event arrives, rather than optimistically updating the full permission state.
#Scaling to more than 10,000+ concurrent attendees
A congress session for a few thousand members has a shape most video products don't design around: a small number of people who need to be seen and heard, and a much larger number who need to watch, listen, chat, and vote, without every one of them also requiring an outbound video stream.
That shape is the actual mechanism behind scaling this to thousands. Most attendees join with presence enabled and sending disabled, counted, visible in the participant list, able to chat and vote, but not pushing a media stream into the room. Speaking access is granted explicitly by the host, which also matches how a formal meeting is meant to run.
A few other design decisions carry real weight at this scale:
The video grid never renders more tiles than physically fit the available screen area, detailed further below. Participant and chat lists are paginated and filtered server-side rather than pulled down whole and searched in the browser, with debounced input so a name search doesn't fire a request per keystroke. Host moderation runs in bulk, muting or disabling video for a full participant list in one call rather than one action per person. Room creation selects a geographic region and enables adaptive simulcast, so Daily adjusts stream quality per participant based on their actual connection, keeping one weak connection from degrading the experience for the room as a whole.
There's also a reconciliation mechanism sitting underneath the WebSocket layer for exactly the case where a real-time event gets missed, a tab was backgrounded during a reconnect, a push notification silently failed to deliver. Rather than polling on a fixed interval regardless of need, a small hook tracks whether the underlying data has actually changed since the last successful fetch, and only issues a request on the next tick if it has.
const tick = useCallback(async () => {
if (!dirtyRef.current || inFlightRef.current) return;
dirtyRef.current = false;
inFlightRef.current = true;
try {
const data = await fetcherRef.current();
onSuccessRef.current?.(data);
} catch (error) {
dirtyRef.current = true; // retry on the next tick
} finally {
inFlightRef.current = false;
}
}, []);At a few dozen concurrent sessions this distinction barely registers. At a few thousand, the difference between "poll every tick" and "poll only when something actually changed" is the difference in backend load between a normal afternoon and a fairly bad one.
#Animation and motion design
Motion in this application is used to reflect state changes rather than as decoration, and one instance in particular is worth calling out for how it's implemented rather than what it looks like.
Each video tile renders a speaking indicator, a ring around the tile that responds to the participant's live audio level. Rather than storing the audio level in React state and re-rendering the tile on every amplitude change, the audio level is read through Daily's audio level observer and piped directly into a motion value, with the tile's border width derived from that value through a transform. The practical effect is that the indicator updates at the rate audio data arrives without triggering a React re-render for every frame, which matters directly at the density this grid needs to support, a session with a hundred tiles rendering speaking indicators is not a context where per-frame re-renders scale acceptably.
Layout-level transitions follow a more conventional pattern. The collapsible side panels animate width and opacity in and out as they're toggled. The primary panel, which shows a pinned camera or screen share, animates height and opacity when switching between pinned states, using an exit-before-enter transition mode so an outgoing panel finishes leaving before the next one enters rather than the two overlapping mid-transition, which previously produced a visible layout jump when a host pinned a new speaker in quick succession.
#Video tile grid layout

The grid computes its layout rather than relying on a fixed CSS structure, since the number of visible tiles, the aspect ratio each one needs to preserve, and the available screen space are all variable inputs.
A resize observer, attached to an element that is never conditionally unmounted so the observer subscription survives layout changes elsewhere on the page, reports the available width and height. From there, the layout function determines how many tiles fit at a minimum usable size, caps the rendered count at that capacity, and then searches a small range of column counts to find the arrangement that produces the largest tile area while preserving a 16:9 aspect ratio.
This is also where the scaling story from the previous section becomes visible on screen. The grid is never asked to mount more video elements than the layout function decides actually fit, regardless of how many participants are technically in the room, so a session with hundreds of cameras on at once degrades gracefully into "shows what fits" rather than attempting to instantiate every tile and letting the browser discover the limit at runtime.
A separate rule governs pinning: a camera tile or a screen share can be promoted to a single primary panel, and the two are mutually exclusive by design, with a guard that checks the pinned participant or share is still actually present before rendering the pinned state, since a stale pin referencing someone who already left is a state the UI should never display.
#Screen share priority
The video grid has exactly one primary panel slot, the larger area above the tile grid used for whichever camera or screen share currently deserves the most visual weight, and only one thing can occupy it at a time. Deciding what wins that slot turned out to need a real priority order rather than a single boolean.
The default case is the one that matters most in practice: when nobody has explicitly pinned anything, an active screen share automatically claims the primary panel over any camera tile. A presentation is almost always what a room actually wants focus on the moment someone starts sharing, so promoting it automatically avoids requiring a host to manually pin a share every single time one starts.
Pinning a camera overrides that default entirely. If a participant or host pins someone's camera, that camera takes the primary panel regardless of whether a screen share is active, and any active screen shares are demoted into the regular tile grid alongside everyone else, rather than disappearing. If more than one screen share is active and one of them is specifically pinned, that one takes priority over the others, which fall back to tiles.
One deliberate exception sits inside this logic: when a specific screen share is pinned, that presenter's own camera tile intentionally stays visible in the grid rather than being hidden alongside their share. The reasoning is straightforward once you think about a real congress session, viewers following a presentation still benefit from seeing the presenter's reactions and expression, not just their slides, so the camera and the screen share coexist rather than one displacing the other from the interface entirely.
The height reserved for the primary panel uses the same formula regardless of whether it's showing a pinned camera or a screen share, the smaller of a fixed proportion of the available container height or a width-constrained value derived from the content's aspect ratio. Camera tiles always use a fixed 16:9 ratio. Screen shares read their actual aspect ratio from the video element's intrinsic dimensions once available, falling back to 16:9 immediately when a local user starts sharing so the panel never renders at zero height while waiting for that value to arrive.
#State management architecture
Early in planning, before much of the meeting logic existed, the design called for a fairly layered structure: UI components communicating through a meeting hook, feeding an event handler layer, feeding an explicit state machine, updating a central store, with a dedicated service wrapping the Daily SDK underneath.
The state machine component, an explicit transitions table mapping raw Daily events to named connection states, is a pattern with real merit, and one worth reaching for when a system's connection lifecycle needs to be testable and reasoned about in isolation from the rest of the application.
During implementation it became apparent that Daily's own React bindings expose a live meeting state hook tracking effectively the same state, driven directly off the SDK's internal event stream. Maintaining a parallel, hand-written version of that state alongside it would not have added correctness, it would have introduced a second source of truth requiring manual synchronization against a state model Daily already owns and updates correctly.
The architecture was consolidated accordingly. Daily's hooks own connection state, participant state, and permissions, since that responsibility sits correctly with the SDK. Jotai owns state specific to this application and outside Daily's concern: active room and token, presence overrides, notification queues, and vote state.
The result is a smaller architecture than the initial design, divided along a clearer line of responsibility, reached by validating the original plan against the platform's actual capabilities rather than by adding more structure on top of an assumption that turned out not to hold.
#Room and token provisioning
Room and token creation go through Daily's REST API on the backend rather than the client SDK directly. A meeting token is a signed credential carrying real authorization, not something that should be minted from client-side code in a production deployment.
The token carries meaningful state beyond simple room access: whether the holder joins as an owner, their initial send and receive permissions, and an expiry tied to the meeting itself rather than a generic session length. A board member's token and a regular attendee's token for the same room differ from the moment they're issued, meaning the permission model is established before media negotiation begins rather than layered on afterward.
Caching the issued token for reuse on rejoin addresses a specific operational case: a participant's connection drops mid-session, commonly mid-vote, and they need to return to the same room under the same identity without re-authenticating from scratch.
#The voting and election system
Video calling is necessary infrastructure here, not the differentiator. The platform exists primarily for its voting system: candidate elections with a nomination and promotion flow, live tallying, and formal result presentation once a vote closes. This runs entirely over the WebSocket layer rather than anything tied to media, since votes need to reach a participant whether their camera is active or their tab is backgrounded.
Every vote-related event, a candidate being promoted, a poll opening, a result being published, is routed through a single typed wrapper around the realtime channel subscriptions rather than components accessing the underlying client directly. Chat, presence, and voting are three distinct feature areas, and giving all three the same event shape meant the implementation patterns converged rather than diverging, which simplified both.

#Closing notes
The most consequential decision in this build wasn't a specific piece of code, it was consolidating two systems that were independently answering the same question about connection state, and retaining the one the underlying platform already answered correctly. Designing a proper architectural layer up front is the right starting instinct. Testing that design against what the SDK actually provides, and simplifying once the overlap becomes clear, is what determines whether the result holds together at a few thousand concurrent participants rather than only in a three-tab demo.