Engineering Case: Evolution of Discord Bot Architecture from Monolith to Hot-Reload Cluster
Engineering Case: Evolution of Discord Bot Architecture from Monolith to Hot-Reload Cluster
Building Discord bots often seems trivial when looking at small weekend projects serving a dozen testing servers. One simply opens the discord.js documentation, hooks a listener onto interactionCreate, writes a few slash commands, and runs the process in the background.
However, the reality of high-load production environments is fundamentally different. When a bot crosses the threshold of thousands of guilds and millions of concurrent members, developers confront the full spectrum of distributed systems challenges:
- WebSocket Gateway Overload: Thousands of incoming JSON packets per second that the Node.js runtime must deserialize without stalling the Event Loop.
- Catastrophic V8 Heap Cache Bloat: Default
discord.jsstructures aggressively allocate every user, channel, role, and message into memory, rapidly exhausting RAM limits. - Privileged Gateway Intents Limitations: Strict Discord API barriers on reading guild rosters (
GUILD_MEMBERS) and message contents (MESSAGE_CONTENT), requiring non-blocking lazy fetching mechanisms. - Database Connection Pool Exhaustion: Direct database queries (MySQL/MongoDB) on every chat event immediately paralyze connection pools and saturate disk I/O.
- Support Service Availability Dilemmas: If a monolithic bot crashes during rolling deployments or unexpected failures, the official support server completely loses ticket handling and member verification right when users need help most.
This article provides a comprehensive chronological analysis of the architectural evolution across three key project generations: Desires, Niako, and Rushia & Osaka. We trace the path from an old-school JavaScript monolith to a distributed WebSocket cluster with zero downtime and hot code swapping on the fly.
1. The Desires Era: Monolith on Vanilla JS and 15,000 Servers

The Desires project served as the first large-scale proving ground. At its peak, the bot served an audience of over 3.5 million users across 15,000 servers, handling moderation, entertainment, virtual economy, and server administration.
Stack and Architectural Context
Desires followed the classical design patterns of the early Node.js ecosystem:
- Core: Pure JavaScript (Vanilla JS),
discord.js v12library (an era prior to granularmakeCachesettings and native Slash Commands). - Interactivity: Classical text prefix commands dispatched via the
messageevent with full string parsing. - API & Web Interface: A separate Express microservice serving lightweight static pages (HTML/CSS), later prototyped on Vue + Nuxt.
- Database: MySQL with raw SQL queries issued directly from event handlers without any caching layer.
- Sharding: Standard
ShardingManagerfromdiscord.js, spawning child worker processes viachild_process.fork.
Severe Challenges of the Monolithic Pattern
While Desires was fully operational and successfully sustained high traffic for a long time, the approach was an old-school monolith with noticeable trade-offs:
-
Embedded Support Loop as a Critical Single Point of Failure: All technical support functionality (ticket management, auto-roles, member verification) resided directly within the main Desires codebase. When peak traffic surges hit or the bot restarted for config updates, the official support server lost all automation. Users arriving to report outages encountered unresponsive ticket buttons.
-
Uncontrolled Cache Bloat in discord.js v12: In
discord.js v12, fine-grained memory quotas (Options.cacheWithLimits) did not exist yet (introduced in v13). By default, the client retained every encountered member, emoji, message, and channel in the V8 heap. Rudimentary message sweepers were insufficient; structures across 15,000 guilds steadily bloated memory toward 1.8–2.0 GB per worker, triggering Stop-the-World Garbage Collection pauses. -
Direct SQL Queries in MySQL Without L1 Cache: Every incoming event triggered a direct SQL query (
SELECT ... WHERE guild_id = ?). With thousands of messages per second, MySQL connection pools exhausted rapidly, causing thread starvation and cascading response latencies.
Desires established a vital architectural rule: a high-load bot cannot remain a monolith, and critical support infrastructure must reside in an isolated, autonomous runtime.
2. The Niako Era: Strict TypeScript, L1/L2 Caching, and Eral Isolation

The Niako project was architected as a systemic evolution addressing the pain points of Desires. Serving 2.5 million users and 10,000 servers, it transitioned entirely to a typed, modular architecture.
Key Innovations in Niako
- Language: Complete migration to TypeScript with strict interface validation.
- Sharding: Standard
discord.jsShardingManager(operating without WebSocket clusters, leveraging standard Node.js process trees). - Dedicated Support Bot Eral: The ecosystem’s first standalone support bot running in an independent process.
- Backend & Dashboard: A robust microservice REST API built on NestJS with Swagger documentation, paired with a React 18 dashboard powered by a custom in-house UI Kit.
Support Infrastructure Isolation: The Eral Bot
The core strategic decision in Niako was creating Eral—a dedicated lightweight bot deployed exclusively to the official support server.
- Eral executed as an isolated process with its own bot token and dedicated database pool.
- It never processed public guilds and remained immune to external traffic surges.
- Support ticket and moderation SLA reached 99.99%: even during full cluster restarts of Niako, the support team maintained uninterrupted operations.
Taming Memory: makeCache and Aggressive Sweepers
Niako introduced rigorous V8 Heap memory quotas using makeCache. All unused entities were disabled at runtime:
makeCache: Options.cacheWithLimits({
...Options.DefaultMakeCacheSettings,
MessageManager: {
maxSize: 50,
keepOverLimit: message => message.author.id === this.user.id
},
ReactionManager: 0,
ReactionUserManager: 0,
AutoModerationRuleManager: 0,
ApplicationCommandManager: 0,
StageInstanceManager: 0,
VoiceStateManager: {
maxSize: 100,
keepOverLimit: state => !state?.mute
},
GuildMemberManager: {
maxSize: 100,
keepOverLimit: member => member.user.bot || member.permissions.has('Administrator')
},
PresenceManager: {
keepOverLimit: presence => !['invisible', 'offline'].includes(presence.status) && !presence?.member?.user?.bot
},
ThreadManager: {
maxSize: 100,
keepOverLimit: thread => thread.type === ChannelType.PrivateThread
}
}),
sweepers: {
...Options.DefaultSweeperSettings,
messages: {
interval: 1_800,
lifetime: 1_800
},
guildMembers: {
interval: 1_800,
filter: () => member => 1 >= member.roles.cache.size
},
users: {
interval: 1_800,
filter: () => user => user.id !== user.client.user.id
}
}
Zeroing reaction managers and applying strict member retention predicates dropped worker RAM from 1.8 GB to a stable 280–320 MB, eliminating Out-Of-Memory crashes.
Two-Tier Database Caching (Mongoose L2 + RAM L1)
To protect MongoDB from thousands of redundant queries per second, subsystems (ModuleSettingManager, ModuleTrackerManager, ModuleRatingManager) implemented a two-tier cache pattern:
export default class ModuleSettingManager {
private cache: Collection<string, TModuleSetting> = new Collection()
constructor(private db: Database) {
setInterval(() => this.sweeper(), 36_000_000)
}
async get(guild: Guild, options: { fetch?: boolean } = { fetch: true }) {
if (this.cache.has(guild.id)) {
return this.cache.get(guild.id)!
}
return !options.fetch ? null : (await this.find(guild.id))
}
async find(guildId: string) {
const doc = await ModuleSettingSchema.findOne({ guildId })
if (doc) {
this.cache.set(guildId, doc)
return doc
}
return await this.create(guildId)
}
async save(doc: TModuleSetting) {
const saved = await doc.save()
this.cache.set(saved.guildId, saved)
return saved
}
private async sweeper() {
const emptyDocs = await ModuleSettingSchema.find({ isDefault: true })
for (const doc of emptyDocs) {
if (!this.cache.has(doc.guildId)) {
await doc.deleteOne()
}
}
}
}
Implementation Benefits:
- Synchronous O(1) Access: 98% of guild configuration requests resolve immediately from the shard’s local
Collectionwithout network I/O. - Lazy Auto-Create: When a server issues a command for the first time, a default document is created atomically and cached.
- 10-Hour Background Sweeper: Periodically purges inactive default records from MongoDB, maintaining lean collection sizes.
3. The Rushia & Osaka Era: Pinnacle of Engineering Design

The Rushia project (originally conceived as NiakoV2) synthesized all accumulated architectural experience, becoming the platform’s most sophisticated iteration.
1. Transition to Embedded High-Speed Hono API
While NestJS performed well in Niako, worker-to-dashboard communication demanded maximal throughput with minimal memory overhead. Rushia adopted Hono (@hono/node-server):
- An embedded REST API runs directly inside each bot worker process.
- Hono’s RegExp-based router executes significantly faster than Express or NestJS with negligible memory footprint.
- Built-in middleware (
hono-rate-limiter, parameter validators) shield local worker endpoints from dashboard traffic surges via Next.js.
2. NiakoCluster WebSocket Cluster on Socket.io
Because Discord enforces a limit of 2,500 guilds per WebSocket shard, multi-shard architectures require strict orchestration. Rushia implemented NiakoCluster:
- A master process coordinates worker nodes over persistent
Socket.ioWebSocket connections. - If a worker crashes or becomes unresponsive, the master initiates an automated
respawn. - A staggered spawn mechanism introduces a 30-second delay for non-zero shards, ensuring strict compliance with Discord’s global Identify Rate Limit (maximum 1
IDENTIFYper 5 seconds per session):
export default class WebSocketManager {
public readonly url: string = `ws://${internal.originalIp}:${internal.ports.clusterWs}`
public readonly shardUrl: string = `ws://${internal.ip}:${internal.ports.shardWs}`
constructor() {
if (!debug) {
this.socket.emit('process')
this.socket.on('respawn', (res: ResponseShardRespawn) => {
this.respawn(res)
})
}
}
private async respawn(res: ResponseShardRespawn) {
if (this.shardManager) {
this.shardManager.shards.forEach(c => c.kill())
this.shardManager.isRespawn = true
delete this.shardManager
}
this.shardManager = new ShardingManager(res)
this.sendCluster(res)
if (!res.shardList.includes(0)) {
await new Promise((resolve) => setTimeout(resolve, 30_000))
}
return this.shardManager.generateShards()
}
}
3. BaseHandler Revolution: Dynamic Hot-Reload Without Shard Restarts
Among the most innovative developments was BaseHandler—a unified module loader powered by the chokidar file system watcher.
Production Problem
In traditional bot architectures, updating a command or correcting a typo required restarting the entire process. Restarts severed active WebSocket sessions with Discord Gateway, silenced thousands of voice channels, and triggered massive Gateway resynchronization storms upon reconnection.
Engineering Solution
BaseHandler monitors the codebase in real time, validates file checksums via md5, and executes targeted runtime cache invalidation via delete require.cache:
import { IBaseModule } from '#types/base/BaseHandler';
import { RushiaClient } from '../client/RushiaClient';
import { readFileSync, readdirSync } from 'fs';
import { Collection } from 'discord.js';
import chokidar from 'chokidar';
import md5 from 'md5';
export default class BaseHandler {
public readonly paths: Collection<string, string> = new Collection()
public readonly cache: Collection<string, any> = new Collection()
constructor(
public client: RushiaClient,
public directory: string,
private options?: { usePathNames: boolean }
) {}
public async loadAll(directory = this.directory) {
const commons = readdirSync(directory)
const directorys = this.getDirectorys(commons)
const files = this.getFiles(commons)
for (let i = 0; directorys.length > i; i++) {
await this.loadAll(`${directory}/${directorys[i]}`)
}
for (let i = 0; files.length > i; i++) {
await this.load(directory, files[i])
}
}
public async load(directory: string, file: string) {
const path = `${directory}/${file}`
if (['ttf', 'otf'].some((f) => file.endsWith(f))) {
this.chokidar(directory, file)
return this.cache.set(path, file)
}
delete require.cache[require.resolve(path)]
const module = (await import(path))?.default as IBaseModule
if (!['object', 'function'].includes(typeof module)) return
this.paths.set(path, md5(readFileSync(path).toString('utf-8')))
switch (typeof module) {
case 'object':
if (module?.options?.disabled) return
if (!module?.options) {
return this.cache.set(file.split('.')[0], module)
}
module.options.dir = directory
this.chokidar(directory, file)
return this.cache.set(this?.options?.usePathNames ? path : (module.options?.name || path), module)
case 'function':
const pull = new (module as any)()
if (!pull?.options || pull?.options?.disabled) return
pull.options.dir = directory
this.chokidar(directory, file)
return this.cache.set(this?.options?.usePathNames ? path : (pull.options?.name || path), pull)
}
}
private chokidar(dir: string, file: string) {
const path = `${dir}/${file}`
chokidar.watch(path).on('add', path => {
if (this.checkUpdate(path)) return
this.load(dir, file)
}).on('change', path => {
if (this.checkUpdate(path)) return
this.load(dir, file)
}).on('unlink', path => {
if (this.checkUpdate(path)) return
this.load(dir, file)
})
}
private checkUpdate(path: string) {
const current = md5(readFileSync(path).toString('utf-8'))
if (current !== this.paths.get(path)) {
this.paths.set(path, current)
return false
} else {
return true
}
}
}
Pipeline Mechanics:
- MD5 Debounce Protection: Operating systems often fire multiple rapid
changeevents on file saves.checkUpdate(path)hashes the file content and compares it against stored values inthis.paths. Identical hashes are discarded instantly. - require.cache Invalidation: Executing
delete require.cache[require.resolve(path)]strips stale compiled modules from Node.js’s internal module registry. - Atomic Re-import:
await import(path)loads the updated module and inserts the fresh instance intothis.cache. - Canvas Font Support: Files with
.ttfor.otfextensions are registered directly into the font cache for image generation components.
Outcome: Command logic, button handlers, and localization adjustments take effect in 10–15 milliseconds in live production without restarting shards or dropping WebSocket sessions.
4. Typed Argument Parsing: BaseArguments
For prefix command support, a custom AST-like argument parser extending Array was built:
- Resolves user mentions, IDs, and usernames into
GuildMemberinstances. - Parses channels, roles, HEX color values, and time intervals (
msstrings like"1d","2h","30m") into strongly typed objects. - Guarantees parameter validation prior to executing command logic.
5. Audio Subsystem: RushiaPlayer on Shoukaku v4 and Lavalink
Real-time audio streaming represents a significant computational bottleneck for Node.js due to Opus packet encoding and decoding.
- Rushia delegates playback through RushiaPlayer, powered by Shoukaku v4.
- Audio rendering occurs on an external cluster of Java-based Lavalink nodes.
- Node.js handles lightweight control signals over WebSocket, keeping the Event Loop unburdened.
6. Isolated Osaka Support Bot
Following the Niako/Eral pattern, the support server for Rushia utilizes Osaka. Operating in an autonomous process with independent database credentials and full BaseHandler integration, it ensures uninterrupted ticket workflows.
4. Architectural Comparison Across Generations
| Parameter | 1. Desires | 2. Niako | 3. Rushia & Osaka |
|---|---|---|---|
| Scale (Users / Guilds) | 3.5M / 15,000 | 2.5M / 10,000 | Pre-release cluster |
| Language | JavaScript (Vanilla JS) | TypeScript (Strict) | TypeScript (Strict, ESM/CJS) |
| Discord Library | Discord.js v12 (Text Commands) | Discord.js v14 (Slash) | Discord.js v14 (Slash + Context) |
| Shard Orchestration | Basic ShardingManager | Discord.js ShardingManager | NiakoCluster (Socket.io Master) |
| Data Cache (L1/L2) | No L1 (Direct MySQL I/O) | L1 In-Memory + Mongo L2 | L1 In-Memory + Mongo 8 + TTL |
| Code Updates | Full Process Restart | Shard Restart | BaseHandler Hot-Reload (chokidar) |
| Internal REST API | Express | NestJS + Swagger | Embedded Hono (@hono/node-server) |
| Web Dashboard | HTML/CSS (v2: Vue/Nuxt) | React 18 + Custom UI Kit | Next.js + Custom UI Kit |
| Support Infrastructure | Built-in (Monolith) | Isolated Eral | Isolated Osaka |
| Audio Subsystem | — | Lavalink v3 | Shoukaku v4 + Lavalink |
5. Key Takeaways and Architectural Principles
The evolution from the Desires monolith to the Rushia cluster established 6 core principles for distributed Discord bot engineering:
-
Never Colocate Support Infrastructure with the Main Bot: Outages in public services under traffic spikes must never disable support ticketing. Standalone support bots (Eral, Osaka) guarantee 99.99% support SLA.
-
Enforce Memory Quotas Down to the First Byte: Default
discord.jsconfigurations retain all Gateway objects. Zero unused managers (ReactionManager: 0,AutoModerationRuleManager: 0) and apply strict retention predicates (keepOverLimit). -
L1 In-Memory Caching is Mandatory for Every Worker: Server configuration lookups must resolve synchronously in RAM at $O(1)$. Database instances should only handle mutations and rare cache misses.
-
Runtime Hot-Reload Preserves Production Stability: Adopting a
BaseHandlerwithchokidarfile watching, MD5 diffing, andrequire.cacheinvalidation enables sub-second patch deployments without dropping Gateway sessions. -
Isolate Computationally Heavy Workloads: CPU-intensive tasks (Opus audio encoding, Canvas card rendering) belong on dedicated worker pools or external services (Lavalink), leaving the Node.js Event Loop responsive to WebSocket traffic.
-
Lean Internal Micro-APIs: Replacing heavyweight server frameworks with lightweight routers like Hono inside workers provides rapid dashboard response times with minimal memory footprint.