The Three Maps

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. userEmitters is the SSE write handle — one per user. userSymbols answers ‘what does this user watch?’ for disconnect and replace. symbolUsers is 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.”