Engineering Case Study: Scaling Discord Bot Ecosystem to Millions of Users & Thousands of Servers

highloaddiscordtypescriptarchitectureshardingnestjshonoredismongodb

Engineering Case Study: Scaling Discord Bot Ecosystem to Millions of Users & Thousands of Servers

Building Discord bots may seem straightforward during early prototyping with a dozen test guilds. However, organically scaling to thousands of servers and millions of active participants rapidly exposes brutal infrastructure limitations: runaway RAM leaks in V8 Heap, WebSocket Gateway saturation, missing Privileged Gateway Intents, database connection pool exhaustion, and cascading process failures.

This article presents a comprehensive technical breakdown of architectural challenges, resource optimizations, and stack evolution across four projects: Desires, Niako, Wind, and Rushia.


1. Project Timeline & Load Metrics

Each project was developed in response to specific product requirements and operational workloads:

  1. Desires (3.5 million users · 15,000 servers):

    • Stack: Vanilla JS, discord.js, standalone Express microservice API, static HTML/CSS web interface (v2 was planned on Vue + Nuxt).
    • Context: The foundational high-load project. The Desires bot monolithically handled both public guild features and its own support server. The project first exposed standard sharding bottlenecks, Node.js event loop congestion, and severe memory leaks.
  2. Niako (2.5 million users · 10,000 servers):

    • Stack: TypeScript, discord.js v14, discord-hybrid-sharding, Redis, MongoDB (Mongoose), full-featured NestJS REST API with Swagger documentation.
    • Interface: Server management dashboard on React 18 with a custom UI Kit.
    • Infrastructure: Dedicated isolated support bot Eral.
  3. Wind (1.0 million users · 1,000 servers):

    • Stack: TypeScript, modular architecture, integration of Open Source solutions.
    • Interface: Management dashboard built on Yandex Gravity UI design system.
  4. Rushia (completed architectural iteration, pre-release):

    • Stack: TypeScript, discord.js v14, embedded high-performance Hono API (@hono/node-server), Mongoose 8, Shoukaku v4 audio engine, inter-process communication via Socket.io.
    • Interface: Dedicated management portal on Next.js with a custom UI Kit.
    • Infrastructure: Dedicated isolated support bot Osaka.
    • Context: Originally conceived as NiakoV2, this codebase evolved into the fully independent, finalized Rushia project.

Comparative Architecture Matrix

Project Audience / Servers Core Bot Stack API & Backend Web Dashboard Support Bot
Desires 3.5M / 15K JavaScript, Discord.js Standalone Express service Vanilla HTML/CSS (v2: Vue + Nuxt) Desires itself (monolith)
Niako 2.5M / 10K TypeScript, Discord.js v14, hybrid-sharding NestJS microservice + Swagger React 18 + Custom UI Kit Eral (isolated)
Wind 1.0M / 1K TypeScript, Open Source modules Embedded REST API Dashboard on Gravity UI
Rushia Pre-release TypeScript, Discord.js v14, Socket.io Hono API (@hono/node-server) Next.js + Custom UI Kit Osaka (isolated)

2. The Gateway Intents Trap & Uncached Entities Handling

A deceptively complex bottleneck in large-scale Discord bots is the Privileged Gateway Intents system. Reaching 100+ servers requires mandatory Discord verification to access message content (MESSAGE_CONTENT), full guild rosters (GUILD_MEMBERS), and status indicators (GUILD_PRESENCES).

The Problem: Partial and Missing Data Structures

Operating without complete member caches invalidates naive synchronous code:

  • guild.members.cache.get(userId) returns undefined in over 90% of cases because the user has not sent a message in the current shard session.
  • Voice updates (voiceStateUpdate), role management, and moderation actions frequently receive partial payloads where member objects lack roles or profiles.
  • Unconstrained calls to guild.members.fetch(userId) on every incoming event instantly trigger global HTTP 429 (Too Many Requests) rate limits from Discord REST API.

Engineering Solution: Lazy Fetching & Dual Command Handlers

To resolve data deficiencies reliably, a multi-stage entity resolution pipeline was engineered:

  1. Graceful Fallback Pipeline: Lookups check the shard local L1 cache first. On miss, a controlled asynchronous fetch is triggered with local debouncing and in-flight request deduplication per user ID.

  2. Dual-Track Command Router: During the platform transition towards Slash Commands, the core maintained a dual architecture: MessageCommandHandler (for legacy prefix guilds) and SlashCommandHandler (for Discord Interactions), completely removing reliance on MESSAGE_CONTENT.

export class CommandDispatcher {
    public async resolveMember(guild: Guild, userId: string): Promise<GuildMember | null> {
        const cached = guild.members.cache.get(userId)
        if (cached && cached.roles.cache.size > 0) {
            return cached
        }

        try {
            return await guild.members.fetch({ user: userId, force: false })
        } catch {
            return null
        }
    }
}

3. Core Challenge: Memory Limits & Discord.js Cache Bloat

By default, discord.js retains almost all received Gateway entities in V8 Heap memory — messages, users, reactions, voice states, emojis, and presences.

The Problem

With tens of thousands of guilds, hundreds of events stream through WebSocket sockets every second:

  • Thousands of servers × hundreds of channels = millions of cached objects in worker memory.
  • Single-process RAM usage quickly exceeded standard Node.js boundaries (1.4–2.0 GB), resulting in fatal JavaScript heap out of memory terminations.
  • Increasing V8 heap limits (--max-old-space-size) merely delayed crashes and introduced multi-second freezes during Garbage Collection Stop-the-World pauses.

Engineering Solution: Granular cacheWithLimits & Active Sweepers

Within Niako and Rushia, a strict memory quota policy was established. Non-essential entity managers were completely zeroed out, while critical managers received explicit limits and retention predicates:

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
    }
}

Optimization Results:

  • Baseline RAM consumption dropped by 30–40% per worker process.
  • Completely eliminated Out-Of-Memory crashes during high-concurrency event surges.

4. Two-Tier Database Caching (MongoDB + L1 In-Memory Collection)

Querying MongoDB directly on every incoming gateway event (autoroles, prefixes, permission checks, autodelete triggers) created immense I/O pressure and triggered connection pool exhaustion.

Modular Manager Architecture

Every business domain was isolated into a dedicated manager (src/db/):

  • ModuleSettingManager — Global guild configurations.
  • ModuleTrackerManager — Voice and text activity analytics.
  • ModuleRatingManager — Economy, experience, and progression tiers.
  • AutoDeleteManager — Automated channel cleanup rules.

Each manager implements a two-tier caching pattern:

  1. L1 In-Memory Collection: Each shard maintains an in-memory map of its active guilds (cache: Collection<string, TModuleSetting>). Lookups complete synchronously in O(1) time.
  2. Lazy-Loading & Auto-Creation: When an unconfigured guild interacts with the bot, a document is created atomically in MongoDB and registered in local shard memory.
  3. Periodic Database Sweeper: A background job runs every 10 hours to purge empty, unmodified documents of inactive guilds, preventing MongoDB collection bloat.
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
    }
}

5. Sharding Evolution: From hybrid-sharding to Custom WebSocket Clustering

Discord protocol limits one WebSocket shard to at most 2,500 guilds. Managing 10,000–15,000 servers required 16–24 active shards.

Cluster Orchestration via WebSocket Manager

Standard sharding libraries spawn shards as child processes on a single machine. To gain complete lifecycle control and fault tolerance, the custom NiakoCluster orchestrator was engineered:

  • A central master controller dynamically balances shard pools across worker nodes over WebSockets (Socket.io).
  • When a worker restarts or encounters an error, the master controller reassigns shard pools to standby nodes without breaking global SLA.
  • Staggered spawn delays prevent violating Discord Identify Rate Limits (maximum 1 IDENTIFY per 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()
    }
}

6. Architectural Isolation: The Role of Support Bots (Eral & Osaka)

A vital operational lesson from production experience: never co-locate mission-critical support infrastructure within the main high-throughput cluster.

During the Desires era, the primary bot handled support ticket workflows, guild verification, and moderation. When the bot went down under heavy loads or during rolling restarts, the support server lost all automation — leaving users unable to open tickets or verify accounts.

To eliminate this vulnerability, starting with Niako, support features were decoupled into standalone, lightweight bots:

  • Eral — Dedicated support bot for the Niako server.
  • Osaka — Dedicated support bot for the Rushia server.

Operating in completely isolated processes with independent database configurations, they guaranteed 99.99% SLA availability for tickets and moderation even during full maintenance shutdowns of the primary bot cluster.


7. API & Dashboard Evolution: Custom UI Kits & Gravity UI

The backend API and web portals evolved to satisfy increasing requirements for responsiveness and data density:

  1. Desires (v1): Monolithic Express API paired with a static HTML/CSS interface.
  2. Niako (v1): Microservice REST API built on NestJS with Swagger documentation and a React 18 dashboard powered by a custom UI Kit.
  3. Wind: Server management dashboard built on Yandex Gravity UI (@gravity-ui/uikit), providing rich visualizations for analytics and modular settings.
  4. Rushia: High-throughput embedded Hono API (@hono/node-server, hono-rate-limiter) with minimal routing overhead and a Next.js dashboard.

8. Key Engineering Takeaways

  1. Intentional Memory Quotas: Explicit makeCache rules and scheduled sweepers are the only reliable defense against V8 memory leaks under heavy WebSocket throughput.
  2. Zero-Assumption State Design: Designing around missing privileged intents with graceful lazy-fetching prevents unexpected production crashes.
  3. Multi-Tier Database Caching: Shard-level L1 in-memory collections shield databases from thousands of redundant queries per second.
  4. Resilience via Service Decoupling: Extracting support bots (Eral, Osaka) into isolated runtimes protects customer assistance channels during core outages.
  5. Adaptive Stack Evolution: Transitioning from Express to NestJS, and ultimately to Hono and custom clustering, enabled the platform to scale smoothly to millions of users with sub-millisecond response times.