BACK TO ENGINEERING
Architecture 9 min read

Our Mentoring Platform Was Bleeding $17 Per Overrun Session. Then I Built a Timer That Kills Zoom Calls.

Article Hero

Here is a scenario that will make any marketplace founder sweat.

A mentee with fifty dollars in credits starts a session with a mentor charging $120 per hour. Forty-seven minutes later, the balance is at $2.77. Neither participant notices. The session runs ten more minutes.

The platform just ate $17.23 in unbillable time.

Do that a few hundred times a month and you have a serious cash flow problem. Every session that overruns a balance is revenue you are subsidizing out of your own pocket.

When I built mentoring.oakoliver.com, I refused to solve this with the lazy approach — polling the database every minute and hoping for the best. Instead, I built an in-memory billing engine that broadcasts millisecond-accurate charges to both participants via Server-Sent Events, escalates warnings at configurable thresholds, and when the balance hits zero, automatically ends the session and terminates the Zoom meeting.

No human intervention. No overruns. No lost revenue.


I – Why I Chose SSE Over WebSockets (And Why You Probably Should Too)

WebSockets are the default answer when developers think "real-time." But for billing updates, they are overkill — and worse, they introduce complexity you do not need.

Billing updates flow in one direction: server to client. The client never sends billing data back. SSE is purpose-built for this pattern.

The EventSource API has built-in reconnection with Last-Event-ID support. With WebSockets, you would build this yourself.

SSE connections piggyback on the same HTTP/2 connection your API calls use. No separate port. No separate TLS handshake. No CORS headaches.

On our Hetzner VPS behind Traefik, SSE works through the reverse proxy with a single header. WebSocket proxying requires additional configuration.

And each SSE connection is just a ReadableStream — a fraction of the memory footprint of a WebSocket connection with its frame parser and ping/pong state machine.

The one thing WebSockets give you that SSE does not is bidirectional communication. But the only "upstream" message we need — heartbeat acknowledgment — is handled by a simple POST endpoint. Problem solved without the complexity.


II – Three Layers, One Source of Truth

The billing system spans three layers, and understanding how they interact is the key to the whole architecture.

The bottom layer is the in-memory billing engine. Every active session lives in a JavaScript Map as a data structure containing the session ID, participant IDs, hourly rate, start timestamp, pause state, accumulated pause duration, the mentee's balance, warning state, event history, and heartbeat tracking per client. A billing monitor runs every thirty seconds, iterating over all active sessions.

The middle layer is the Elysia API route. A GET endpoint that authenticates the participant, creates a ReadableStream, registers the SSE client, replays any missed events, sends an initial billing snapshot, and starts a heartbeat every fifteen seconds.

The top layer is the React hook. It manages the EventSource lifecycle, handles billing updates, warnings, pause/resume events, and session end events, implements exponential backoff reconnection, and acknowledges heartbeats via POST.

The critical insight is that billing state lives in memory, not in the database. The database is the source of truth for completed sessions. But the active session timer runs entirely in a JavaScript Map. This eliminates database round-trips during the billing loop, which runs every thirty seconds across all active sessions.


III – The Calculation That Makes Pennies Accurate

The core billing calculation happens every thirty seconds for every active session. It needs to be fast and it needs to be precise.

Active duration is calculated as: current time minus start time, minus total accumulated paused time, minus any ongoing pause duration. This single formula accounts for any number of pauses within a session, accumulated into a running total.

The current charge is derived from active duration converted to hours, multiplied by the hourly rate. Remaining balance is the mentee's starting balance minus the current charge. Remaining time is the remaining balance divided by the hourly rate, converted back to minutes.

Two design decisions matter here.

The calculation returns both elapsed minutes and elapsed seconds. The minutes tell the human "seven minutes." The seconds tell the React component to render "07:32." This eliminated the need for client-side timer interpolation — the server is the single source of truth for elapsed time.

Financial values are stringified to two decimal places at the calculation boundary. Not before. Not after. Right at the point where the number leaves the engine. This prevents accumulating floating-point rounding errors across thirty-second ticks. The client displays exactly what the server calculated.


IV – Pause and Resume: The Edge Case That Builds Trust

Pause and resume sounds simple until you realize that paused time must be excluded from billing with millisecond accuracy. If a mentor pauses for a bathroom break lasting three minutes and twelve seconds, the mentee must not be charged for that time.

When a session is paused, the current timestamp is recorded. When it resumes, the delta between now and the pause timestamp is added to a running total of accumulated pause time. The pause timestamp is then cleared.

Multiple pauses within a single session are no problem. They all accumulate into the same running total. The billing calculation always subtracts this total from wall-clock time to get active duration.

Both the pause and resume events are broadcast immediately via SSE so both participants see the state change in real time. The UI shows a pause indicator and freezes the timer display.

This precision matters for trust. If users suspect they are being charged during pauses, they leave the platform. Millisecond-accurate pause accounting is not a technical nicety. It is a business requirement.


V – The Warning System: A State Machine for Money

The billing monitor does not just calculate charges. It also manages a warning state machine.

At ten minutes remaining, the first warning fires. Both participants see a medium-urgency alert: "Session will end in 10 minutes due to balance limit."

At five minutes remaining, the second warning fires. High urgency. Same message, different countdown.

A tracking variable ensures each warning fires exactly once. Without this, the thirty-second billing loop would fire the five-minute warning repeatedly for every tick where the remaining time is under five minutes. The state machine transitions from null to ten-minute warning to five-minute warning, and each transition happens once.

This is a small piece of state that prevents a terrible user experience. Nobody wants to receive the same warning notification twenty times in a row.


VI – Auto-End: When the Balance Hits Zero

When remaining balance reaches zero and remaining minutes hit zero, the billing monitor triggers the auto-end sequence. This is the most consequential function in the entire service.

Step one: retrieve the Zoom meeting ID from the database. The session record links to the Zoom meeting.

Step two: terminate the Zoom meeting via API. The Zoom SDK on both participants' browsers fires its onMeetingEnd callback.

Step three: calculate the final billing snapshot. Exact elapsed time, exact charge, exact remaining balance.

Step four: persist the final state to PostgreSQL. Session status, end time, duration, total cost. This is the permanent record.

Step five: broadcast the session_ended event to all SSE clients. Both participants see the session end screen with the final charge and duration.

Step six: clean up in-memory state. Remove the session from the Map. Unregister SSE clients. Stop heartbeats.

The order matters. Zoom terminates before the end event broadcasts to clients. If we broadcast first, the client might navigate away while the Zoom SDK is still active, creating a messy disconnection. By killing Zoom first, the SDK disconnects cleanly, then the SSE event arrives to update the UI.

Notice that Zoom termination is wrapped in error handling that does not abort the rest of the flow. If Zoom's API is down or the meeting already ended, we still finalize billing and notify clients. The Zoom call is best-effort. The billing settlement is mandatory.


VII – Heartbeats: Because SSE Connections Lie

SSE connections can silently die. A mobile user switches to another app. Their carrier drops the connection. But the server's ReadableStream does not know.

These ghost connections accumulate memory and skew client counts. Without detection, you end up broadcasting billing updates into the void.

The solution is a heartbeat-acknowledge cycle.

Every fifteen seconds, the SSE stream sends a heartbeat event with a timestamp and client ID. On receiving the heartbeat, the client POSTs back to an acknowledgment endpoint with the client ID.

The billing monitor checks the last acknowledgment time for each client. If a client has not acknowledged in sixty seconds — missing four consecutive heartbeats — it is considered stale and gets unregistered.

This is a pragmatic middle ground. True bidirectional heartbeats would require WebSockets. Our approach adds a single POST every fifteen seconds per client — negligible overhead for reliable disconnect detection.


VIII – Reconnection Replay: Never Missing a State Transition

Clients disconnect. Networks hiccup. Browsers suspend tabs. The system must handle this gracefully.

Every billing event gets a unique ID. The last hundred events are stored in the session's event history. When a client reconnects with a Last-Event-ID header, the SSE route replays everything that happened after that ID.

New connections without a Last-Event-ID get the last ten events. Enough context to render the current billing state even if the initial snapshot was missed.

Unknown Last-Event-IDs also get the last ten events. This handles server restarts where the event counter was lost. The client gets a reasonable state instead of an empty replay.

The hundred-event cap prevents memory growth. A two-hour session at thirty-second intervals generates about 240 billing updates plus warnings. Keeping the last hundred is enough for any realistic reconnection window.


If real-time billing, SSE architecture, or marketplace payment design is something you are working through — I have been building and refining these patterns for production use. Book a session at mentoring.oakoliver.com and we will dig into your specific architecture. If you need a micro-SaaS platform with real-time capabilities built in, see vibe.oakoliver.com.


IX – What Happens When the Server Restarts

This is the one genuine weakness of in-memory billing state. If the Bun process crashes, all active sessions lose their timers.

But it is recoverable.

Sessions in the database still have their active status. On the next API call for that session, the billing-stream route detects the session is not tracked in memory and re-initializes the timer from database state.

The start timestamp comes from the database, not from the current time. So billing resumes from the correct origin, not from the restart time.

Paused time during the crash is approximated. The database status tells us whether the session was paused, and we treat the crash-to-restart gap as paused time.

Is this perfect? No. Could a five-second crash window cause a billing inaccuracy of a few cents? Yes. Is that an acceptable tradeoff versus the complexity of persisting timer state to Redis on every tick? For our scale, absolutely.


X – The Production Numbers

After three months in production on mentoring.oakoliver.com, the results are clear.

Average concurrent billing sessions: two to five. Memory per session: roughly four kilobytes including event history. SSE message size: about 200 bytes per billing update. Billing monitor CPU per tick: under one millisecond for five sessions. Balance overrun incidents: zero.

Twenty-three sessions were auto-ended due to balance depletion. Nineteen of those had their Zoom meetings auto-terminated. Four had already ended manually.

The system handles the load on our single Hetzner VPS — shared with five other services — without showing up in CPU profiling.

SSE is underrated. For server-to-client streams, it is simpler, more reliable, and easier to operate than WebSockets. In-memory state is fine for ephemeral data that lasts minutes to hours. Heartbeats are non-negotiable for SSE. And auto-end is the killer feature that makes users feel safe.

The core pattern — in-memory tracking with SSE broadcasting and automatic cleanup — applies to any real-time billing scenario. Ride-sharing. Cloud compute metering. Pay-per-minute consulting. Even parking meters.

What is the most complex billing edge case you have encountered — and how did you solve it?

– Antonio

"Simplicity is the ultimate sophistication."