Chat Guard started as a single Node.js process and grew into something with sharding, a disciplined database layer, and its own fraud detection. None of that arrived by design up front. Every growing pain forced a better architectural decision, and the shape of the system is the accumulated record of those decisions.
The Naive Architecture
The first version was one index.js file, one database connection, one event loop handling everything. It worked well at first and started showing cracks as load grew: memory leaks from uncollected event listeners, database connection timeouts under load, and the occasional complete crash when Discord's gateway hiccuped.
Single Node.js Process
├── Discord.js Client (one shard)
├── MongoDB Connection (single)
├── Command Handler
├── Event Listeners (message, join, leave)
└── Moderation Logic
Sharding: The First Real Architecture Decision
Discord forces sharding once a bot exceeds 2,500 servers, but the smart move is to shard earlier. Each shard maintains its own WebSocket connection to Discord's gateway, handling a subset of the total servers, and runs as an independent process, isolated from the others. If one crashes, the rest keep running.
Shard Manager
├── Shard 0
├── Shard 1
├── Shard 2
└── ...
I implemented sharding well before Discord required it, and it was one of the best architectural decisions made early on.
The Database Layer
MongoDB was the right choice for this use case, and I will defend that position. Discord bot data is inherently document-shaped: each server has its own configuration, its own log entries, its own moderation history. The schema varies per server because admins customise their setups differently.
But MongoDB at this kind of write load requires discipline.
Pattern 1: The Per-Guild Document
The most natural schema for Discord bots is one document per server (guild). Each document contains that server's configuration, moderation settings, and metadata.
{
"_id": "guild_123456789",
"prefix": "!",
"features": {
"automod": true,
"logging": true,
"welcomeMessage": false
},
"moderators": ["user_111", "user_222"],
"updatedAt": "2023-01-15T10:30:00Z"
}This works because Discord data is naturally partitioned by guild. You almost never query across guilds, so every operation scopes to one server.
Key insight: use the guild ID as _id. Every collection gets a unique _id index whether you want one or not, so you may as well make it earn its keep. Do not waste it on an autogenerated ObjectId when you have a perfect natural key. The pay-off is that every config lookup becomes a primary-key hit with no secondary index to maintain, and inserts cannot create duplicate guild rows.
Pattern 2: Capped Collections for Logs
Moderation logs grow fast, and storing all of them indefinitely is expensive and unnecessary. Most admins only look at the last 30 days. MongoDB's capped collections are purpose-built for this:
db.createCollection("modlogs", {
capped: true,
size: 104857600, // 100MB
max: 500000 // 500k documents
})Capped collections automatically evict the oldest documents when the size or document limit is reached. No TTL indexes, no cleanup jobs, no manual pruning. Insertion order is preserved on disk, so a natural-order scan returns logs newest-last for free. The trade-offs are real: documents cannot be deleted individually, an update must not grow a document beyond its original size, and the collection cannot be sharded. For append-only logs none of these bites, but reach for a TTL index instead the moment you need per-document expiry or selective deletion.
Pattern 3: Compound Indexes That Match Your Queries
MongoDB can only use one index per query (with some exceptions for $or). If you are querying by guild ID and sorting by date, you need a compound index.
// Bad: Two separate indexes
db.modlogs.createIndex({ guildId: 1 })
db.modlogs.createIndex({ createdAt: -1 })
// Good: One compound index that serves both
db.modlogs.createIndex({ guildId: 1, createdAt: -1 })The compound index handles find({ guildId: "xxx" }).sort({ createdAt: -1 }) in a single index scan. Two separate indexes force MongoDB to choose one, then sort in memory, which is orders of magnitude slower, and an in-memory sort over more than 32MB aborts the query outright.
Rule of thumb (ESR): order index fields Equality, then Sort, then Range. Matching that order lets a single index satisfy both the filter and the sort, and you can confirm it with .explain("executionStats"): look for an IXSCAN feeding the results directly, with no SORT stage in the plan.
Pattern 4: Bulk Write Operations
Chat Guard does not write to MongoDB on every event. It buffers writes and flushes them in bulk.
const writeBuffer = [];
const FLUSH_INTERVAL = 5000; // 5 seconds
const MAX_BUFFER_SIZE = 100;
function bufferWrite(operation) {
writeBuffer.push(operation);
if (writeBuffer.length >= MAX_BUFFER_SIZE) {
flush();
}
}
async function flush() {
if (writeBuffer.length === 0) return;
const operations = writeBuffer.splice(0);
await collection.bulkWrite(operations, { ordered: false });
}
setInterval(flush, FLUSH_INTERVAL);The { ordered: false } flag tells MongoDB it can execute operations in any order, which allows parallelism. It also changes failure behaviour: an ordered bulk write stops at the first error, whereas an unordered one attempts every operation and reports the failures at the end. For logging and analytics, where one malformed document should not block the other ninety-nine, that is exactly what you want.
Two things worth flushing on, beyond the size and interval triggers above: flush on a timer so a quiet buffer still drains, and flush on shutdown so you do not lose the tail of the buffer when the process exits.
Pattern 5: Projection, Only Fetch What You Need
Fetching entire documents when you only need two fields is a common mistake, and with guild documents that contain nested configuration objects it matters.
// Bad: Fetches the entire document
const guild = await db.guilds.findOne({ _id: guildId });
const prefix = guild.prefix;
// Good: Only fetches the prefix field
const guild = await db.guilds.findOne(
{ _id: guildId },
{ projection: { prefix: 1 } }
);This reduces network transfer, memory usage, and deserialisation time, and it adds up under sustained event volume.
Pattern 6: Schema Versioning
Over two years, the guild configuration schema changed at least 15 times. New features added, old ones deprecated, field names changed. Without schema versioning, migrations become nightmares.
{
"_id": "guild_123456789",
"_schemaVersion": 3,
"config": { ... }
}On read, check the version. If it is outdated, run a lazy migration: update the document to the current schema and save it. Documents migrate only when they are actually touched, so you never run an expensive batch migration over every row, most of which may never be read again. The cost is that your read path must understand every historical version, so keep a migration function per version step and chain them; once almost everything has been read at least once, a final background sweep can retire the old code path.
What Did Not Work
- Embedded arrays for large datasets. MongoDB documents have a 16MB limit. An embedded array of moderation logs hits that limit faster than you would expect.
- GridFS for small files. The overhead is not worth it for files under 1MB. Use base64 encoding or an external store.
- Replica sets without read preference configuration. Default read preference is
primary, which defeats the purpose of having replicas for read scaling.
Rate Limiting: Respecting Discord's API
Discord's rate limits are strict, and for good reason. Every API call (banning a user, sending a message, modifying a channel) has a rate limit. Exceed it, and you are blocked. Exceed it repeatedly, and your bot gets flagged.
I built a queue system using an in-memory priority queue with exponential backoff. Every API call goes through the queue, which respects per-route, per-guild, and global rate limits. It sounds like overengineering until you realise the alternative is your bot randomly failing to moderate because it hit a rate limit mid-operation.
Event Processing Pipeline
Every message, every join, every leave, every reaction flows through the same event pipeline:
- Receive event from Discord gateway
- Filter: is this server using this feature?
- Validate: does the bot have the required permissions?
- Process: execute the moderation logic
- Log: write to the audit log (batched)
The critical step is the second one. Most events can be discarded immediately because the server does not have the relevant feature enabled. This early-exit pattern cut the processing load dramatically.
Invite Tracking and Attribution
Server admins need to know where their members are coming from: which invite links are working, which promoters are bringing real users, and which are flooding the server with fake accounts.
Discord's API gives you invite codes and their use counts, but not a direct per-join attribution. The fix is differential tracking: capture the invite counts before and after each join event, then calculate which invite code's count increased.
// On member join:
// 1. Fetch current invite counts
// 2. Compare with cached counts from before the join
// 3. The invite whose count increased by 1 = the invite used
// 4. Update cacheThis sounds simple, but if two members join within milliseconds of each other, the differential tracking can produce race conditions. I solved this with a per-guild mutex that serialises join event processing.
Each join produces an attribution record:
{
"guildId": "123456",
"userId": "789012",
"inviterId": "345678",
"inviteCode": "abc123",
"joinedAt": "2023-03-10T14:22:00Z",
"accountAge": "2 days",
"flags": ["new_account"]
}Over time this builds a complete map of who invited whom, useful for running referral programmes or finding which members are actually bringing people in.
The invite cache itself, storing invite counts for every active invite across every server, was the part that grew unwieldy under load. Moving from in-memory caching to Redis, keyed by guild ID, solved it: Redis allows horizontal scaling (multiple bot shards sharing the same cache) and persistence across restarts. The trade-off is latency, since Redis adds a network hop compared to in-memory access, but the reliability was worth it.
Fake Account Detection
Fake accounts, created in bulk to inflate server numbers or spam, have telltale patterns.
Account age. Accounts created within the last 7 days are flagged, within the last 24 hours escalated. Legitimate users occasionally have new accounts, but bulk-created fake accounts almost always have creation dates within hours of joining.
Join velocity. If the same invite link is used by several accounts within minutes of each other, that is suspicious. Legitimate invite sharing produces a steady trickle of joins; fake account floods produce bursts.
Behavioural signals. After joining, fake accounts tend to go silent (real users at least look around), send immediate DM spam to members, or carry a default avatar. None of these alone is conclusive, but the combination of a new account, a default avatar, and a burst join pattern is a strong signal.
Each heuristic that fires contributes a weighted amount to a combined risk score between 0 and 1:
- Account age < 24h: 0.4
- Burst join pattern: 0.3
- Default avatar: 0.1
- No activity after 1 hour: 0.2
A combined score above 0.7 triggers automatic action, configurable per server: kick, ban, quarantine role, or just flag for review.
I considered training a classifier for this instead. Hand-tuned heuristics were faster to implement, they were easier to explain to admins, and in production they turned out more predictable.
Admins see the results in a real-time dashboard: a leaderboard of who has invited the most people, a breakdown of which links are performing best, risk alerts for flagged accounts, and retention data showing what percentage of invited members are still active after 7 days. The retention view is the one that changes how admins read the rest of the data. An inviter who brings a hundred members who all leave within a day is less valuable than one who brings ten members who become active community participants.
What I Would Do Differently
If I were building Chat Guard from scratch today:
- TypeScript from day one. The refactoring pain of adding types to a large JavaScript codebase is real.
- Better observability. I added structured logging too late. For the first year, debugging production issues meant reading through console.log statements.
- Lock down differential tracking earlier. The race condition in invite attribution should have had a mutex from the first version, not after the first incident.
Every automated action, from moderation to fake account handling, had to stay configurable, because what counts as spam in one server is normal in another, and admins need to be able to override it.