A Discord bot in C# is a .NET console app that holds one outbound WebSocket connection to Discord's gateway and reacts to events. Pick a library - Discord.Net is the most widely used and documented, DSharpPlus is the long-standing alternative, NetCord is the newest and built for modern .NET - enable only the gateway intents you need, register slash commands rather than parsing message text, and run the whole thing under the .NET Generic Host so it starts, logs and shuts down properly. It needs no inbound port, very little CPU, and between 60 and 200 MB of memory for most bots, which is why the smallest hosting plan is usually enough.
This post builds a bot with Discord.Net from an empty project to a slash command, then covers what changes when it runs on a server around the clock.
Choosing a library#
All three libraries cover the gateway, the REST API, slash commands, buttons, select menus and modals. The differences are maturity, style and how closely each tracks Discord's newest features.
| Library | Package | Style | Notes |
|---|---|---|---|
| Discord.Net | Discord.Net | Event-based, mature | Largest user base and the most examples |
| DSharpPlus | DSharpPlus | Event-based, extension packages | Version 5 has been in long prerelease; check which one guides use |
| NetCord | NetCord | Modern .NET, hosting-first | Newer, targets current .NET only, close to the Discord API |
Choose Discord.Net if you want the answer to any question to be one search away. Choose NetCord if you are starting fresh on .NET 8 or later and like libraries designed around dependency injection and the Generic Host from the start. Choose DSharpPlus if you already know it; otherwise, check the current state of its version 5 before committing, because examples written for version 4 do not carry over directly.
The examples below use Discord.Net 3.x. The concepts - intents, command registration, acknowledging interactions, the host - are Discord's, not the library's, and apply to all three.
The application, the token and intents#
Before any code, create the bot in the Discord Developer Portal:
- Create a New Application, then open the Bot page.
- Reset and copy the token. It is shown once. Anyone with it can act as your bot in every server it is in.
- Turn on any privileged intents the bot needs (below).
- Under OAuth2, generate an invite URL with the
botandapplications.commandsscopes and only the permissions the bot actually uses. Open it and add the bot to a test server.
Intents decide which events Discord sends you. Most are ordinary; three are privileged and must also be switched on in the portal:
| Intent | Privileged | You need it for |
|---|---|---|
Guilds | No | Almost everything - server and channel data |
GuildMessages | No | Message events in servers |
MessageContent | Yes | The text of messages not addressed to the bot |
GuildMembers | Yes | Member join and leave events, full member lists |
GuildPresences | Yes | Online status and activities |
The one that catches everybody is MessageContent. Without it, message events still arrive but Content is an empty string, except for messages that mention the bot or are direct messages to it. A bot built on slash commands does not need it at all, which is the main reason to build on slash commands. Once a bot is in 100 or more servers, privileged intents require Discord's approval through bot verification, so do not request what you do not use.
The same restraint applies to permissions in the invite URL. It is tempting to tick Administrator so nothing ever fails with "Missing Permissions", and it turns a leaked token from an annoyance into the loss of every server the bot is in: an attacker with an administrator bot can delete channels, ban members and hand out roles. Grant the specific permissions the commands use - Send Messages, Embed Links, Manage Roles if the bot assigns roles - and remember that role hierarchy still applies: a bot can only manage roles below its own highest role, whatever permissions it holds. Subusers and least privilege makes the same argument for the hosting side.
A minimal bot on the Generic Host#
Create a worker project and add the packages:
$ dotnet new worker -n TavernBot$ cd TavernBot$ dotnet add package Discord.NetThe Generic Host gives you configuration, logging, dependency injection and clean shutdown for free. The bot itself is a hosted service that starts the client when the host starts and stops it when the host stops:
using Discord;using Discord.Interactions;using Discord.WebSocket;var builder = Host.CreateApplicationBuilder(args);builder.Services.AddSingleton(new DiscordSocketConfig{ GatewayIntents = GatewayIntents.Guilds});builder.Services.AddSingleton<DiscordSocketClient>();builder.Services.AddSingleton(sp => new InteractionService(sp.GetRequiredService<DiscordSocketClient>()));builder.Services.AddHostedService<BotService>();builder.Build().Run();public sealed class BotService( DiscordSocketClient client, InteractionService interactions, IServiceProvider services, IConfiguration config, ILogger<BotService> log) : IHostedService{ public async Task StartAsync(CancellationToken ct) { client.Log += m => { log.LogInformation("{Message}", m.ToString()); return Task.CompletedTask; }; await interactions.AddModulesAsync(typeof(BotService).Assembly, services); client.Ready += async () => await interactions.RegisterCommandsToGuildAsync(config.GetValue<ulong>("Discord:GuildId")); client.InteractionCreated += async i => await interactions.ExecuteCommandAsync(new SocketInteractionContext(client, i), services); await client.LoginAsync(TokenType.Bot, config["Discord:Token"]); await client.StartAsync(); } public async Task StopAsync(CancellationToken ct) { await client.StopAsync(); await client.LogoutAsync(); }}Many tutorials skip the host and end Main with await Task.Delay(-1) to keep the process alive. That works until you need anything else: there is no clean shutdown, so the bot is killed mid-request on every restart; there is no dependency injection, so every module news up its own database connection; and there is no configuration system, so the token ends up hard-coded. The host costs a dozen extra lines and removes all three problems. It also means the same bot can later be dropped into an ASP.NET Core app unchanged, since a web app is a Generic Host too.
The token comes from configuration, so on a server it is the environment variable Discord__Token, never a string in the code. ASP.NET Core configuration and secrets explains the double underscore; the same configuration system runs in a worker. If a token is ever pushed to a public repository, reset it in the portal immediately. Discord takes part in GitHub's secret scanning and often invalidates leaked tokens on its own, which is a rescue rather than a plan.
Slash commands#
With the interaction framework, a command is a method on a module:
public sealed class GeneralModule : InteractionModuleBase<SocketInteractionContext>{ [SlashCommand("ping", "Check the bot is alive")] public Task Ping() => RespondAsync($"Pong - {Context.Client.Latency} ms"); [SlashCommand("roll", "Roll a die")] public Task Roll([MinValue(2), MaxValue(100)] int sides = 6) => RespondAsync($"You rolled {Random.Shared.Next(1, sides + 1)}"); [SlashCommand("report", "Build a server report")] public async Task Report() { await DeferAsync(ephemeral: true); var text = await BuildSlowReportAsync(); await FollowupAsync(text, ephemeral: true); } private static async Task<string> BuildSlowReportAsync() { await Task.Delay(5000); return "All quiet."; }}Three rules of Discord's interaction model shape every command:
- Respond within three seconds. If a command needs longer - a database query, an HTTP call, anything slow - call
DeferAsync()first. The user sees "thinking", and you then have 15 minutes to send the real answer withFollowupAsync. A command that misses the three seconds shows "The application did not respond", even if your code finishes successfully a moment later. - Register commands, do not just declare them. Discord has to know a command exists before users can see it. The example registers to one test server with
RegisterCommandsToGuildAsync, which takes effect immediately. For production, register globally withRegisterCommandsGloballyAsync; global changes have historically taken longer to appear, so develop against a guild and switch at release. - Do not re-register on every start without need. Registration replaces the whole command set. It is harmless, but it is an API call, and a bot that crashes in a loop and re-registers each time is one way to meet Discord's rate limits.
Options are validated by Discord before they reach you - MinValue and MaxValue above are enforced in the client - so the method receives values in range. Buttons, select menus and modals use [ComponentInteraction] and [ModalInteraction] handlers in the same modules.
Hosting it 24/7#
A gateway bot connects out to Discord; nothing connects in. It needs no port, no domain and no certificate. What it needs is a process that stays up and comes back when it does not, which is exactly what a hosting panel does. Host a Discord bot 24/7 covers the general options, from a spare PC to a VDS.
On a panel with Git deploy the start command publishes and runs the bot:
dotnet publish -c Release -o out --disable-build-servers && exec dotnet out/TavernBot.dllexec makes the bot the main process, so the panel's stop signal reaches the Generic Host, which calls StopAsync, which closes the gateway connection cleanly. A bot killed without that leaves a "ghost" presence online for a short while until Discord times the connection out.
The things that matter on a server:
- Log to the console. The host's console logger writes to stdout, which the panel shows live. Gateway disconnects and reconnects are normal; Discord.Net logs them and reconnects on its own. A bot that logs a reconnect every few seconds has a network or token problem.
- Expect restarts. Deploys, updates and the occasional crash restart the process. Store anything that must survive - settings per server, user data, cooldowns - outside memory.
- Keep a separate test bot. Create a second application in the Developer Portal for development, with its own token, invited only to a private test server. Running your local copy with the production token is how two processes end up fighting over one gateway session, and how half-finished commands appear in your community's servers.
- Unhandled exceptions in event handlers. Discord.Net runs handlers on its own tasks and logs exceptions from them, but a handler that blocks for a long time stalls the gateway. Keep handlers short and push slow work into a background task or a queue, as described in .NET worker services and background jobs.
On RE:NODE, a C# / .NET plan deploys the bot from a GitHub repository, takes the token as an environment variable on the Startup tab, and restarts the process if it exits. Deploy on push restarts only a server that was already running, so a bot you stopped deliberately stays stopped. The crash watcher counts unexpected restarts: three in an hour put a warning on the server page and open a ticket automatically, which is how a bot stuck in a crash loop gets noticed. Picking a plan for a Discord bot covers sizing.
Memory, caching and sharding#
A bot's memory is mostly the library's cache of Discord state: servers, channels, roles, and - if you ask for them - members and messages.
Setting (DiscordSocketConfig) | Default | Effect on memory |
|---|---|---|
MessageCacheSize | 0 | Messages kept per channel; 0 keeps none |
AlwaysDownloadUsers | false | Download every member of every server at start |
GatewayIntents | unprivileged set | Fewer intents, fewer events and less cached |
A bot in a handful of servers with the defaults sits comfortably under 150 MB. Turning on GuildMembers with AlwaysDownloadUsers in servers with tens of thousands of members is what pushes a bot to gigabytes. Download members only for the servers and moments you need them.
A worker project uses Workstation GC by default, which keeps memory lower than an ASP.NET Core app would; .NET memory and garbage collection explains the difference. On RE:NODE, a process that reaches the plan's memory limit is stopped and restarted clean rather than swapping, so a cache that grows without bound shows up as periodic restarts rather than a slow bot.
Sharding splits the gateway connection across several sessions, each handling a subset of servers. Discord requires it from 2,500 servers. Discord.Net provides DiscordShardedClient for it. Below that threshold, a single connection is simpler and entirely adequate.
Where the bot keeps its data#
Anything that matters must outlive the process. The options, in order of effort:
- A JSON file for a few settings. Fine until two commands write it at once; use a lock.
- SQLite through EF Core or
Microsoft.Data.Sqlite. One file, no server, real transactions. Keep the file outside the folder your deploy replaces. - A database server once more than one process reads the data, or once you want backups handled for you. An app plan here includes two database slots with generated credentials; .NET with PostgreSQL, MySQL or SQL Server covers the providers.
Cooldowns and rate limits per user can live in memory if losing them on restart is acceptable, which for a bot it usually is.
Troubleshooting#
The bot is online but slash commands do not appear. They were not registered, were registered to a different server, or the invite lacked the applications.commands scope. Re-invite with both scopes and check the registration call in the Ready handler ran without an error in the log.
"The application did not respond". The command took longer than three seconds without deferring, or threw an exception before responding. Check the log, then add DeferAsync() to anything that does I/O.
Message content is empty. The MessageContent intent is missing in code, in the portal, or both. Or move the feature to a slash command.
"Authentication failed" or a 401 at login. The token is wrong, has a stray quote or space from the environment variable, or was reset. Copy it again.
The bot disconnects repeatedly. Two processes are running with the same token - often a local copy you forgot about and the server copy - and Discord keeps closing one of them. Stop the local one.
FAQ#
Which library is best for a first C# bot?
Discord.Net, mainly because of the volume of examples and answers available. NetCord is a strong choice for a new bot on current .NET if you are comfortable with the Generic Host.
Does a Discord bot need a public IP or open port?
Not a gateway bot, which connects outward. Only a bot that receives interactions over HTTP instead of the gateway needs a public HTTPS endpoint; on an app plan the proxy slot can provide one with an automatic certificate.
How much memory does a C# Discord bot need?
Usually 60 to 200 MB for a bot in a modest number of servers with default caching. Downloading full member lists for large servers is what changes that.
Can I run the bot and a web dashboard in one app?
Yes. Use an ASP.NET Core app and register the bot as a hosted service in it; both share configuration and the database. The dashboard then needs the port and the proxy slot, and the bot keeps its outbound gateway connection.
Why does my bot appear online for a while after I stop it?
It was killed rather than stopped, so it never closed its gateway session, and Discord waits for the connection to time out. Make sure the start command uses exec and the host calls StopAsync.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.