BearLog

Blog ·

Who gets a socket

The last post ended on a wall I hadn’t solved yet: Supabase’s free plan allows 200 concurrent Realtime connections, and Queue was opening one per waiting customer’s Tracker. Egress is a budget, it refills on the first of the month and responds to trimming bytes. A connection ceiling isn’t a budget. It’s a ceiling, and nothing about the size or frequency of a message moves it. I called it “connection pooling” in that post’s teaser, which was a mistake I want to correct up front: pooling is a different Supabase subsystem entirely, for Postgres, on a different port, and has nothing to do with this. The question here is narrower and better named who gets a socket.

The shape of the number

At the old design, a busy venue held one connection per TV, one per staff tab, and one per waiting customer:

C = venues × (TVs + staff tabs) + customers holding a socket

A modest venue is 1 TV, 3 staff tabs, 15 people waiting when it’s busy. That’s 19 connections per venue, which puts the ceiling at:

200 ÷ 19 ≈ 10 busy venues

Ten. Not ten at once system-wide on a viral day, ten venues, each having an ordinary Tuesday morning. The thing that decided how many providers Queue could carry wasn’t how good the product was. It was how many of their customers happened to be waiting in the same hour.

The instinct I tried first, and why it was wrong

My first idea was to tier the subscription itself: give the front 3 customers in line a socket, since they’re the ones about to be called, and poll everyone behind them. It works, sort of:

today, everyone subscribes     19 / venue     10 venues on 200
top-3 realtime                  7 / venue     28 venues on 200

Better. Wrong direction. The problem with tiering the subscription is that it keeps the coupling that caused the wall in the first place: the connection count is still a function of how busy your customers are. A quiet Tuesday costs less, a bad Monday still eats the same ceiling it always did, just at a higher venue count before it bites.

What actually breaks the coupling is refusing to give a customer a socket at all:

no customer sockets              4 / venue     50 venues on 200

Four instead of nineteen: one TV, three staff. The connection count is now a function of how many venues have been sold to, which is a number BearLog controls, not one that depends on whether a Monday is bad.

The two surfaces that keep theirs

A socket goes to a surface whose count is bounded by the venue, not by its customers. That’s the Public Display and the staff board:

  • The Display earns its socket more than anything else in the system. One connection serves an entire waiting room, and the room is where a called token is actually read off the TV.
  • Staff keep one per tab, bounded by headcount, because a Counter has to see what the Counter beside it just did. A staff member’s own action already comes back on the mutation response; the socket exists only for the other operators.

Everything a customer holds gives its socket up. The Tracker, the screen a waiting customer keeps open on their phone, goes poll-only. So does the pre-join listing a customer browses before they’ve even joined.

Replacing the socket with a poll that knows where you are

The Tracker’s new interval, in lib/queries/hooks.ts:

export function trackerInterval(
  tracker: TrackerProjection | undefined,
): number | false {
  if (tracker && isResolved(tracker.state)) return false;
  const ahead = tracker?.aheadCount ?? 0;
  if (ahead <= 5) return 10_000;
  return ahead <= 20 ? 30_000 : 60_000;
}

Ten seconds inside the last five, thirty out to twenty, sixty beyond that, stopped outright once the entry resolves. Freshness is worth a lot at the sharp end of the line and worth nothing at the back, so the cadence follows position instead of sitting at one flat number for everybody.

Sixty seconds, not longer, at the far tier was deliberate. A second counter opening can clear twenty people in a few minutes, and a longer interval is long enough to be called while you’re not looking.

The poll only works because the query got cheaper first

Polling more often would have been a bad trade if the read behind it still cost what it used to. The old Tracker read the same fifteen-column projection the staff dashboard reads, for every entry in the session, just to tell one customer how many people were ahead of them. That made the app’s hottest read cost customers × queue length, and it only got more expensive as the day went on.

The new one, readTrackerLine, answers the only question the Tracker actually has:

export async function readTrackerLine(
  sessionId: string,
  ownCreatedAt: string,
  completedSample: number,
): Promise<TrackerLine> {
  const [ahead, serving, completed] = await Promise.all([
    db.from("queue_entries")
      .select("id", { count: "exact", head: true })
      .eq("session_id", sessionId).eq("state", "waiting")
      .lt("created_at", ownCreatedAt),
    db.from("queue_entries")
      .select("token, called_at")
      .eq("session_id", sessionId).eq("state", "serving"),
    db.from("queue_entries")
      .select("called_at, completed_at")
      .eq("session_id", sessionId).eq("state", "completed")
      .not("completed_at", "is", null)
      .order("completed_at", { ascending: false })
      .limit(completedSample),
  ]);
  // ...
}

The waiting rows never leave Postgres, a head count returns none of them. The serving rows are bounded by how many counters exist, not how long the line is. The completed sample carries the two timestamps the wait estimate needs and nothing else. Roughly a tenth of the bytes, and flat in queue length rather than growing with it. That’s what makes polling four and a half times more often at the front still cheaper than the old flat cadence:

before   120 polls × ~40 KB   ≈ 4.8 MB   (from the last post)
new      tiered, tiny payload            ≈ 5.1 MB, 6% more, sockets to zero

Six percent more bytes bought back the entire ceiling. That’s the trade I’d take every time: egress refills, connection count doesn’t.

The display’s idle socket

One more leak, smaller but easy to miss: a TV left on overnight held a connection all weekend just to keep a screen reading “the queue is closed” up to date. It wasn’t the busy peak setting the connection count on a venue that leaves the screen powered, it was the idle one. The Display now holds its socket only while the session is open, and drops to a 30-second poll while closed, so opening the queue again is noticed on the next tick instead of instantly. Cheap fix, and it turns out idle time was the more common case.

The limit that was actually closer

While I was looking at this, a second ceiling turned up with the same shape. Broadcast bills per message delivered, not per message sent, so fifty people watching one queue turned one “next please” into fifty billed messages. The free plan includes 2M messages a month, and a queue serving 200 people a day with fifty average watchers burns through that in about six busy queues, a smaller number than the 34,000 monthly visits egress allows for, and one that arrives sooner than the 200-connection wall did.

I’d been ready to split the broadcast topic per customer to fix this, routing each person’s own call to them alone instead of broadcasting to everyone watching the queue. Turned out not to be necessary yet: with customers off the socket entirely, a queue has about four subscribers left instead of fifty, and the message math moves with it. Connections now bind at fifty venues, messages at roughly ninety-five. Connections are the sooner limit again, so the topic split stays on the shelf as the documented next move rather than something I built for a problem removing sockets already mostly solved.

The number that would have mattered eventually

None of this is close to urgent at today’s size, but it’s worth knowing where the next wall is before building toward it. At a thousand paying providers:

connections   1000 × 4                        = 4,000 peak
              (4000 - 500 included) ÷ 1000 × $10   ≈ $35/mo
messages      ~5,280 events/venue/mo × 4 subscribers × 1000 venues
                                               = 21M msg/mo
              (21M - 5M included) × $2.50/M          ≈ $40/mo
Pro plan                                              = $25/mo
                                              total ≈ $100/mo

A hundred dollars a month at a thousand providers is not a problem worth architecture for. The thing actually worth watching at that scale is the rate limit, not the bill: Pro allows 500 messages a second, and a thousand venues each calling roughly once every thirty seconds lands around 133 a second. Four times headroom, and it’s a hard limit rather than an overage, so the fix at that point is a support ticket asking Supabase for a higher quota, not a rewrite.

What I took from it

The last post’s mistake, in hindsight, was treating this like the same kind of problem as egress. Egress is consumption: fewer bytes, fewer polls, a cheaper host, and the number moves. A connection ceiling isn’t consumption, it’s a slot, and the only lever on a slot count is deciding who’s allowed to hold one. I spent a day looking for a clever way to make Realtime cheaper per customer before noticing the actual fix was to stop giving customers a connection at all.

The other thing worth naming: my first fix, tiering who gets realtime, was measurably better and still the wrong answer, because it optimised the number without touching what the number was a function of. Fifty venues against twenty-eight isn’t the interesting gap. “Depends on venues sold” against “depends on how busy customers get” is. A capacity number that scales with something the business controls is worth more than a capacity number that’s merely bigger.