The whiteboard for this problem is in
excalidraw/4. Designing Realtime Abuse Master.excalidraw. This file is the
full write up of that board.
1. Problem Statement
One video live stream is powered by one server. Every participant of that stream, up to a maximum of 100, is connected to the same server over a WebSocket. Participants can send text messages that are broadcast to everyone in the stream.
We want abusive words masked before the broadcast goes out, so shit becomes
****. The creator's video comes in over RTMP, but video is not what we are
filtering. We only care about the chat messages, because that is where abuse
happens.
2. Requirements
2.1 Functional Requirements
| Requirement | Detail |
|---|---|
| Broadcast chat | A participant message reaches everyone else in the same stream |
| Mask abuse | Known abusive words are replaced with asterisks before broadcast |
| Configurable dictionary | An internal team can add and remove words without a deploy |
| Dictionary reaches servers | Updated lists reach every live stream server |
| Handle variants | Catch sh1t, s h i t, shiiit and similar evasions |
| Repeat offenders | Track users who keep trying, and mute or kick them |
| Audit trail | Keep what was flagged, for appeals and for tuning the list |
2.2 Non Functional Requirements
- Very low latency. Masking must add well under 10 ms. Chat that lags behind the video feels broken.
- No extra network hop per message. At 100 participants each sending messages, a remote call per message is not affordable.
- Available. If the dictionary cannot be fetched, the server must still start and still serve chat.
- Scales by stream. Thousands of concurrent streams, each on its own server process, with no shared bottleneck on the hot path.
- Fast dictionary updates. A newly banned word should be live everywhere within a minute or two.
2.3 Out of Scope
Image and audio moderation, video frame analysis, machine learning based intent detection. Those are follow ups in section 11.
3. Understanding the Architecture
- The creator's stream is captured over RTMP.
- Participants connect to the same server over a WebSocket.
- A participant can send a message to everyone.
Because every message in one stream passes through the same server, that server is the natural place to filter. There is no need to coordinate with anything else to moderate a single stream.
3.1 How Socket.IO Fits
- Socket.IO gives real time client to server communication.
- Clients connect over WebSocket, with a fallback transport if needed.
- Server and clients talk in named events, so
chat:sendandchat:messageare separate channels of meaning. - Rooms group clients. One stream is one room.
- The server can send to one client, to everyone, or to one room.
Rooms are what make broadcast cheap. io.to(streamId).emit(...) reaches exactly
the participants of that stream.
4. The Abuse Dictionary
Assume the list of banned words lives in a text file in blob storage such as S3, at a known path. When a live stream server starts up it:
- Downloads the abuse file.
- Loads it into memory.
The question is which data structure.
4.1 Approach 1: Tokenize and Look Up
- Load all words into a hash set.
- Tokenize the incoming message on whitespace and punctuation.
- For each token, check membership in the set.
- On a hit, replace the token with asterisks. On a miss, copy it through.
Cost is O(n) in message length with O(1) average lookups, and it is about ten lines of code. Good enough to ship.
Where it falls short:
- It only catches exact whole tokens.
shittyis missed because the token does not equalshit. - Substring search fixes that but then you are running the whole dictionary against every message, which is dictionary size times message length.
- It cannot catch a phrase spread across tokens.
4.2 Approach 2: Trie
Build a trie of the banned words once at startup, then walk the message a single character at a time and follow trie edges.
root
/ \
s f
| |
h u
| |
i c
| |
t k
(end) (end)Properties:
- One pass over the message, and the cost per character does not depend on how many words are in the dictionary.
- Prefix matching comes free, so
shittyis caught as soon as the walk reaches an end node. - Multi word phrases work by keeping spaces as edges.
- An Aho-Corasick automaton, which is a trie plus failure links, finds every match in one pass with no backtracking at all. That is the production version of this idea.
The trie is what we put in the live stream server.
4.3 Handling Evasion
Before the lookup, normalize:
| Trick | Example | Normalization |
|---|---|---|
| Leetspeak | sh1t, @ss | Map digits and symbols to letters |
| Spacing | s h i t | Collapse repeated separators inside a candidate run |
| Repetition | shiiiit | Collapse three or more identical letters to one |
| Case | SHIT | Lowercase everything |
| Unicode lookalikes | Cyrillic а for Latin a | Normalize to NFKC, then map confusables |
Normalization runs on a copy. We mask by index range in the original string, so the user still sees their own punctuation and capitalization everywhere the text was clean.
5. Why Not a Separate Moderation Service
The tempting design is a POST /clean-text microservice that the stream server
calls per message. Think about it realistically.
| Cost | Detail |
|---|---|
| Network call per message | Adds a round trip to every single chat line |
| TCP setup | A three way handshake per call if connections are not pooled |
| TCP teardown | A two way teardown after each response |
| Even with keep alive | Still real network input and output per message, plus serialization both ways |
| Failure surface | The moderation service being slow makes all chat slow |
At 100 participants per stream and thousands of streams, that is a huge amount of avoidable traffic to answer a question that a local trie answers in microseconds. Creating a service to clean the text is a bad idea.
The better approach: the live stream server itself downloads the abuse dictionary from S3, builds the trie in memory, and broadcasts the masked message.
The dictionary is small, a few thousand words is well under a megabyte, and every server holding a full copy is completely fine. This is a read mostly, tiny, shared configuration, which is exactly the kind of data you replicate everywhere instead of centralizing.
6. Configuring the Abuse List
We need a way for an internal team to maintain the list.
- A UI where the current words are listed.
- The ability to add or remove words and publish them to S3.
That is the abuse admin service.
The database is the source of truth for editing, with history and who changed what. The S3 file is the compiled snapshot that servers actually read. Editing and serving are separated on purpose: a thousand servers pulling one immutable S3 object is trivial, a thousand servers querying one database is not.
Two ways for a running server to learn about an update:
- Poll. Fetch the version marker every 60 seconds, and re download only if it changed. Simple, no extra infrastructure, worst case one minute stale.
- Push. The admin service publishes a message on a pub sub channel that every server subscribes to, and servers re download immediately. Faster, needs a broker.
Poll is the sane default, and push is worth adding once minutes of staleness starts mattering.
Rebuilding the trie happens off the request path. The server builds a new trie into a separate variable and swaps the reference when it is ready, so no message ever waits on a rebuild.
7. API Design
7.1 Admin REST API
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/abuse-words | List words, paginated and searchable |
| POST | /api/admin/abuse-words | Add a word or phrase |
| PATCH | /api/admin/abuse-words/:id | Change severity or action |
| DELETE | /api/admin/abuse-words/:id | Remove a word |
| POST | /api/admin/abuse-list/publish | Compile and push a new snapshot to S3 |
| GET | /api/admin/abuse-list/version | Current published version marker |
| GET | /api/admin/flagged-messages | Audit view of what got masked |
Add a word:
{
"word": "badword",
"language": "en",
"severity": "HIGH",
"action": "MASK",
"matchType": "SUBSTRING"
}Publish response:
{
"version": 42,
"wordCount": 3184,
"s3Key": "abuse/lists/v42.txt",
"checksum": "sha256:9f2a...",
"publishedAt": "2026-08-19T10:31:00Z"
}7.2 Server to S3 Contract
| Method | Path | Purpose |
|---|---|---|
| GET | s3://moderation/abuse/lists/current.json | Small pointer file with version and key |
| GET | s3://moderation/abuse/lists/v42.txt | The immutable word list for that version |
The pointer file is tiny, so polling it every minute costs nothing. The list
files are immutable and versioned, which means a rollback is just pointing
current.json back at v41.
7.3 WebSocket Events
| Direction | Event | Payload |
|---|---|---|
| Client to server | chat:send | { streamId, text, clientMsgId } |
| Server to room | chat:message | { msgId, userId, text, maskedCount, sentAt } |
| Server to sender | chat:blocked | { clientMsgId, reason } |
| Server to sender | chat:warning | { strikes, limit } |
| Server to room | moderation:muted | { userId, untilSec } |
| Server to client | dictionary:version | { version } for debugging |
The sender gets chat:message with their own masked text too. If we showed the
sender the raw version and everyone else the masked version, the sender would
never learn the word is banned and would keep trying.
8. Data Model
Two very different shapes here: a small relational set of rules, and a high volume append only log of what was flagged.
8.1 Relational Tables
abuse_words
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
word | text | Unique with language |
language | char(2) | en, hi, es |
severity | enum | LOW, MEDIUM, HIGH |
action | enum | MASK, BLOCK, FLAG_ONLY |
match_type | enum | EXACT, SUBSTRING, PHRASE |
is_active | boolean | Soft disable without losing history |
created_by | uuid | Foreign key to internal_users.id |
created_at | timestamptz |
Severity drives what happens: MASK replaces the word, BLOCK drops the whole
message, FLAG_ONLY broadcasts it unchanged but records it for review.
abuse_list_versions
| Column | Type | Notes |
|---|---|---|
version | integer | Primary key, increments on publish |
s3_key | text | Where the compiled snapshot lives |
word_count | integer | |
checksum | text | Servers verify the download |
published_by | uuid | Foreign key to internal_users.id |
published_at | timestamptz | |
is_current | boolean | Exactly one true row |
allowlist_terms
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
term | text | Never masked, wins over abuse_words |
reason | text | Why it is exempt |
The allowlist exists because substring matching produces embarrassing false positives. Place names and ordinary words that contain a banned substring go here.
streams
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
creator_id | uuid | Foreign key to users.id |
server_id | text | Which live stream server hosts it |
status | enum | LIVE, ENDED |
max_participants | integer | 100 in this design |
started_at | timestamptz |
user_strikes
| Column | Type | Notes |
|---|---|---|
user_id | uuid | Primary key part, foreign key to users.id |
stream_id | uuid | Primary key part, foreign key to streams.id |
strike_count | integer | Attempts in this stream |
muted_until | timestamptz | Null when not muted |
last_strike_at | timestamptz |
Strikes are scoped per stream, so one bad night does not follow a user forever. The live counters sit in Redis for speed and get flushed here for durability.
8.2 Append Only Log
flagged_messages (Cassandra, DynamoDB or Elasticsearch)
| Column | Type | Notes |
|---|---|---|
stream_id | uuid | Partition key |
flagged_at | timestamp | Clustering key, newest first |
message_id | uuid | |
user_id | uuid | |
original_text | text | Kept for appeals |
masked_text | text | What was broadcast |
matched_words | list | Which rules fired |
list_version | integer | Foreign key idea, points at abuse_list_versions.version |
action_taken | enum | MASK, BLOCK, FLAG_ONLY |
Storing list_version is what makes tuning possible later. When someone asks
why a message was masked, the answer is reproducible because we know exactly
which dictionary was live.
8.3 Relationships
In words:
- One internal user maintains many words and publishes many versions.
- One published version explains many flagged messages, each flagged message points at exactly one version.
- One user creates many streams, one stream has one creator.
- One stream contains many flagged messages and many strike rows.
- One user accumulates one strike row per stream they participate in.
Note that abuse_words and flagged_messages are not directly joined. The
snapshot version sits between them, because the words table changes and the log
must stay explainable.
9. Key Flows
9.1 Server Startup
If the S3 fetch fails, the server loads the last good copy from local disk. If there is no local copy either, it loads a small dictionary bundled with the build and logs loudly. The server always starts. Chat with a stale filter is much better than no chat.
9.2 A Message Being Broadcast
The log write is asynchronous and fire and forget. Nothing about broadcasting a message should wait on an audit write.
9.3 Publishing a New List
Order matters. The word list file is uploaded before the pointer file. A server polling in between sees the old pointer and the old list, which is consistent. The reverse order would briefly point at a file that does not exist yet.
10. Failure Handling
| Failure | Effect | Handling |
|---|---|---|
| S3 unreachable at startup | No fresh dictionary | Load local disk copy, then the bundled default. Always start |
| S3 unreachable during a refresh | Cannot update | Keep serving the current in memory trie, retry with backoff |
| Corrupt list file | Trie build could fail | Verify the checksum first, keep the old trie on any failure |
| Trie build throws | Server could crash | Build into a new variable inside a try block, only swap on success |
| Live stream server dies | Participants disconnect | Clients reconnect, the orchestrator reassigns the stream to a new server. Chat history is not this server's job |
| Flagged log store down | Audit records lost | Buffer in memory with a bounded queue, drop oldest under pressure. Never block a broadcast |
| Redis down | No rate limits or strike counts | Fall back to per process in memory counters, masking is unaffected because it is local |
| Bad word published, huge false positives | Legitimate chat masked | Rollback is a single pointer flip to the previous version, plus the allowlist for one off cases |
The pattern across this table: masking depends only on process local state, so almost nothing outside the process can stop chat from being filtered.
11. Tradeoffs
| Decision | Chosen | Alternative | Reason |
|---|---|---|---|
| Where to filter | In the live stream process | Separate moderation service | No network hop per message, microseconds instead of milliseconds |
| Data structure | Trie, or Aho-Corasick | Hash set of tokens | Catches substrings and phrases in one pass |
| Dictionary distribution | S3 snapshot plus poll | Direct database query per server | One immutable object serves thousands of servers |
| Update mechanism | Poll every 60 s | Pub sub push | Simple and dependency free, staleness of a minute is acceptable |
| Snapshot files | Immutable and versioned | Overwrite one file | Rollback is a pointer flip, and logs stay explainable |
| Sender view | Sees their own masked text | Sees raw text | Teaches the sender the word is banned |
| Audit write | Asynchronous | Synchronous | Broadcast latency must not depend on logging |
| Strike counters | Redis with periodic flush | Relational writes per event | Chat volume is far too high for a write per message |
12. Exercise: Build a Small Version
From the board, a scoped down project you can actually write:
Moderated real time chat
- Socket.IO server. A Node.js server handling many concurrent connections and broadcasting messages.
- Dynamic data fetching. On startup, fetch a list of prohibited words from a URL using an HTTP client.
- Optimal storage. Store the words in a hash set for constant average time lookup, then upgrade to a trie.
- Linear time masking. Walk the message once, check candidates against the set, replace matches with asterisks.
- Event cycle. Client sends a raw message, the server intercepts and filters it, the server broadcasts the sanitized message to all clients.
- Error handling. If the fetch fails, fall back to a bundled list so the server still starts.
13. Future Work
- Aho-Corasick instead of a plain trie, for guaranteed single pass matching of all patterns.
- Per language dictionaries selected by the stream's locale.
- Context aware classification. A small model scoring intent, since word lists cannot tell insult from quotation.
- Image and audio moderation for shared media and creator speech, which is asynchronous work and belongs in a queue and worker pipeline, not in the hot path.
- Creator controls. Per stream strictness, and creator managed extra words.
- Feedback loop. Mine
flagged_messagesfor near misses and evasion patterns, and feed them back into the dictionary.
14. Interview Talking Points
- Say early that every message in one stream already passes through one server. That is what justifies local filtering.
- Reject the moderation microservice explicitly, with the handshake and teardown cost as the reason. This is the main insight in the problem.
- Move from hash set to trie by naming the concrete case the hash set misses,
which is
shittyversusshit. - The dictionary is small, read mostly configuration, so replicating it to every server is correct rather than wasteful.
- Upload the list before the pointer. Ordering details like this are what separate a design that works from one that mostly works.
- Fail open on the dictionary and fail safe on the trie swap. Chat must never go down because a word list could not be fetched.