What you should do next
With requirements agreed, draw the three maps before writing connect logic. The interviewer wants to see that you know why fan-out is O(1) by symbol, not that you memorized “use pub-sub.”
The three maps
userEmitters: Map<userId, SseEmitter> one SSE connection per user
userSymbols: Map<userId, Set<symbol>> which symbols a user is currently watching
symbolUsers: Map<symbol, Set<userId>> which users are watching a given symbol
Each map answers a different question:
| Map | Question it answers |
|---|---|
userEmitters |
Where do I send events for this user? |
userSymbols |
What is this user watching? (for disconnect cleanup and replace) |
symbolUsers |
Who is watching this symbol? (for fan-out on publish) |
These three maps form a bidirectional index between users and symbols. The emitter map is the third leg — it holds the write handle, not subscription state.
Example state
Three users, overlapping watchlists:
userEmitters: { U1 → emitter1, U2 → emitter2, U3 → emitter3 }
userSymbols: { U1 → {AAPL, TSLA},
U2 → {AAPL, INFY},
U3 → {TSLA} }
symbolUsers: { AAPL → {U1, U2},
TSLA → {U1, U3},
INFY → {U2} }
Draw this on the board. Every later operation is a walk through one of these structures.
Three traversal paths
Every operation in this system uses one of three paths through the maps:
┌─────────────────────────────────────┐
│ THREE MAPS │
└─────────────────────────────────────┘
FAN-OUT (publish) DISCONNECT ADD / REMOVE
───────────────── ────────── ────────────
symbol userId userId + symbol
│ │ │
▼ ▼ ▼
symbolUsers userSymbols userSymbols
│ │ │
▼ ▼ ▼
{U1, U2, ...} {AAPL, TSLA, ...} add/remove symbol
│ │ │
▼ ▼ ▼
userEmitters symbolUsers symbolUsers
│ │ │
▼ ▼ ▼
emitter.send() remove userId add/remove userId
from each symbol
Fan-out path
Given a price update, find all users watching it and send:
publish("AAPL", price)
→ symbolUsers["AAPL"] = {U1, U2}
→ userEmitters["U1"] = emitter1 → emitter1.send(AAPL, price)
→ userEmitters["U2"] = emitter2 → emitter2.send(AAPL, price)
This is O(watchers for that symbol), not O(all users). For a popular symbol like AAPL with many watchers, you still only touch subscribers — you never scan users who are not watching.
Disconnect path
Given a user leaving, clean up every symbol registration:
disconnect("U1")
→ userEmitters.remove("U1")
→ userSymbols.remove("U1") = {AAPL, TSLA}
→ symbolUsers["AAPL"].remove("U1")
→ symbolUsers["TSLA"].remove("U1")
After disconnect, no state for U1 remains in any map. The next publish("AAPL") will not find U1.
Add/remove path
Given a user changing their watchlist, update both maps together:
addSymbol("U1", "INFY")
→ userSymbols["U1"].add("INFY")
→ symbolUsers["INFY"].add("U1")
removeSymbol("U1", "TSLA")
→ userSymbols["U1"].remove("TSLA")
→ symbolUsers["TSLA"].remove("U1")
If you update only one map, fan-out and disconnect both break silently.
Walk through publish(“AAPL”)
Starting from the example state above:
Step 1: publish("AAPL", 185.42) arrives from upstream feed
Step 2: symbolUsers.get("AAPL") → {U1, U2}
Step 3: for each userId in {U1, U2}:
emitter = userEmitters.get(userId)
emitter.send(SseEmitter.event()
.name("AAPL")
.data("{\"symbol\":\"AAPL\",\"price\":185.42}"))
Step 4: U1 and U2 receive the event on their single SSE connection
U3 does not — U3 is not in symbolUsers["AAPL"]
U3 is watching TSLA only. AAPL publish never touches U3’s emitter. That is the point of the inverted index.
Why not one map only?
Candidates sometimes propose a single map: Map<userId, Set<symbol>> and scan all users on every publish. That fails the interview for three reasons:
Fan-out cost. Publishing AAPL requires iterating every connected user and checking if they watch AAPL. With 10,000 users and 10 symbols each, every tick scans 10,000 entries. The inverted index reduces this to only AAPL watchers.
Disconnect cost. Without userSymbols, you still know what a user watches — but without symbolUsers, disconnect requires scanning every symbol in the system to remove the user. With hundreds of symbols, that is O(symbols) per disconnect instead of O(user’s watchlist size).
Missing emitter handle. Subscription state and connection state are different concerns. userSymbols tracks interest; userEmitters tracks the live HTTP connection. A user could theoretically have symbols registered before connect completes — keeping them separate makes lifecycle clear.
ONE MAP (weak): userId → {symbols} publish scans ALL users
TWO MAPS (better): userSymbols + symbolUsers fan-out OK, no emitter
THREE MAPS (right): + userEmitters fan-out + send + lifecycle
Consistency rule
Every mutation that changes who watches what must update both userSymbols and symbolUsers in the same logical operation:
| Operation | userSymbols | symbolUsers |
|---|---|---|
| addSymbol | add symbol to user’s set | add userId to symbol’s set |
| removeSymbol | remove symbol from user’s set | remove userId from symbol’s set |
| disconnect | remove user’s entire set | remove userId from each symbol in set |
| replaceSymbols | replace user’s entire set | remove from old symbols, add to new |
Violating this rule produces ghost subscriptions (fan-out to users who removed the symbol) or ghost registrations (symbolUsers still lists a disconnected user).
What to say on the board
“I need three maps.
userEmittersis the SSE write handle — one per user.userSymbolsanswers ‘what does this user watch?’ for disconnect and replace.symbolUsersis the inverted index for fan-out — given AAPL, who cares? Publish walks symbol → users → emitters. Disconnect walks user → symbols → unregister. Add and remove touch both subscription maps together.”
That sentence, plus the example state diagram, is enough for the interviewer to nod and say “go implement connect.”