Skip to main content

Command Palette

Search for a command to run...

Building a tiny IRC client in C#

Updated
12 min read
Building a tiny IRC client in C#
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

IRC is old, plain, and surprisingly useful as a teaching tool. You open a TCP connection. You send text commands. The server sends text lines back. The connection stays open. If the server sends PING, you respond with PONG, or you get disconnected. That’s already a very different model from a normal web API. HTTP teaches request and response. IRC teaches connection lifetime. This post builds a tiny IRC client in C#. The goal isn’t to build a production IRC bot. The goal is to understand what sits underneath higher-level frameworks, sockets, streams, protocols, parsing, heartbeats, cancellation, and failure.

What we’re building

We’re going to build a small console app that connects to an IRC server, registers a nickname, joins a channel, reads messages, and responds to server heartbeats.

The core flow looks like this:

That’s enough to teach the interesting parts.

The application has a few small pieces. One part owns the connection. One part sends IRC commands. One part reads lines from the server. One parser turns raw IRC lines into a simple C# type.

This is deliberately small. The code should stay close to the protocol, otherwise the useful lesson gets buried.

IRC is just lines of text

A basic IRC command is a line of text ending in CRLF, which means \r\n.For example, to set a nickname, the client sends this:

NICK tiny-csharp-bot

To provide user information, it sends this:

USER tiny-csharp-bot 0 * :Tiny CSharp Bot

To join a channel, it sends this:

JOIN #some-channel

The server sends lines back in a similar shape. Some are numeric replies. Some are messages from users. Some are server commands.

The first small but important detail is line endings. Network protocols are often fussy about tiny details. A command can look correct in your code and still fail because the server expected \r\n, not just \n.

That’s the kind of thing frameworks usually hide from you.

Start with the connection

Here’s a small IRC client class that connects over TCP and wraps the stream in TLS. Most public IRC networks expect TLS on port 6697. Plain TCP on port 6667 still exists in some places, but using TLS is a better default even for a toy client.

using System.Net.Security;
using System.Net.Sockets;
using System.Text;

public sealed class IrcClient(string host, int port, string nick, string channel)
    : IAsyncDisposable
{
    private TcpClient? _tcpClient;
    private StreamReader? _reader;
    private StreamWriter? _writer;
    private SslStream? _sslStream;

    public async Task ConnectAsync(CancellationToken stopToken)
    {
        _tcpClient = new TcpClient();
        await _tcpClient.ConnectAsync(host, port, stopToken);
        _sslStream = new SslStream(_tcpClient.GetStream());
        await _sslStream.AuthenticateAsClientAsync(host);
        _reader = new StreamReader(_sslStream, Encoding.UTF8);
        _writer = new StreamWriter(_sslStream, Encoding.UTF8)
        {
            NewLine = "\r\n",
            AutoFlush = true
        };

        await SendAsync($"NICK {nick}");
        await SendAsync($"USER {nick} 0 * :Tiny CSharp Bot");
        await SendAsync($"JOIN {channel}");
    }

    public async Task RunAsync(CancellationToken stopToken)
    {
        if (_reader is null)
        {
            throw new InvalidOperationException("Client is not connected.");
        }

        while (!stopToken.IsCancellationRequested &&
               await _reader.ReadLineAsync() is { } line)
        {
            Console.WriteLine($"< {line}");
            var message = IrcMessageParser.Parse(line);
            await HandleMessageAsync(message, stopToken);
        }
    }

    private async Task HandleMessageAsync(IrcMessage message, CancellationToken stopToken)
    {
        if (message.Command.Equals("PING", StringComparison.OrdinalIgnoreCase))
        {
            var token = message.Trailing ?? message.Parameters.FirstOrDefault();
            if (!string.IsNullOrWhiteSpace(token))
            {
                await SendAsync($"PONG :{token}");
            }
            return;
        }

        if (message.Command.Equals("PRIVMSG", StringComparison.OrdinalIgnoreCase))
        {
            var from = message.Prefix ?? "unknown";
            var target = message.Parameters.FirstOrDefault() ?? "unknown";
            var text = message.Trailing ?? string.Empty;
            Console.WriteLine($"{from} -> {target}: {text}");
        }
    }

    private async Task SendAsync(string line)
    {
        if (_writer is null)
        {
            throw new InvalidOperationException("Client is not connected.");
        }

        Console.WriteLine($"> {line}");
        await _writer.WriteLineAsync(line);
    }

    public async ValueTask DisposeAsync()
    {
        if (_writer is not null)
        {
            await _writer.DisposeAsync();
        }

        _reader?.Dispose();
        _sslStream?.Dispose();
        _tcpClient?.Dispose();
    }
}

There’s no ASP.NET Core here. No request object. No response object. No middleware. We’re just reading and writing lines over a stream. That’s the first useful lesson. A network connection is a stream. Your application decides what the bytes mean.

The small console app

The console app itself is boring, which is what we want.

using var cancellation = new CancellationTokenSource();

Console.CancelKeyPress += (_, eventArgs) =>
{
    eventArgs.Cancel = true;
    cancellation.Cancel();
};

await using var client = new IrcClient(
    host: "irc.libera.chat",
    port: 6697,
    nick: "tiny-csharp-bot",
    channel: "#test-channel");

await client.ConnectAsync(cancellation.Token);
await client.RunAsync(cancellation.Token);

For a real run, you’d use your own nickname and a channel where testing is allowed. Don’t point a toy bot at a busy public channel and spam it while you debug. That’s a bad idea! The important part is the shape of the program. The client connects once, then keeps reading until cancellation or disconnection. That alone makes it feel different from a normal HTTP endpoint. There’s no single request to finish. The connection is the work.

Parsing the message

At first, you can get away with string checks. If the line starts with PING, send PONG. That works for the first version, but it doesn’t teach enough. So let’s parse the message into a small model. An IRC message can have a prefix, a command, some parameters, and trailing text. A normal line can look like this:

:nick!user@host PRIVMSG #channel :hello from IRC

The prefix is this part:

nick!user@host

The command is this:

PRIVMSG

The first parameter is this:

#channel

The trailing text is this:

hello from IRC

A small record is enough for this post.

public sealed record IrcMessage(
    string? Prefix,
    string Command,
    IReadOnlyList<string> Parameters,
    string? Trailing);

Now we need a parser.

public static class IrcMessageParser
{
    public static IrcMessage Parse(string line)
    {
        var remaining = line.AsSpan();
        string? prefix = null;
        string? trailing = null;

        if (remaining.StartsWith(":"))
        {
            var prefixEnd = remaining.IndexOf(' ');
            if (prefixEnd < 0)
            {
                return new IrcMessage(
                    Prefix: line[1..],
                    Command: string.Empty,
                    Parameters: Array.Empty<string>(),
                    Trailing: null);
            }

            prefix = remaining[1..prefixEnd].ToString();
            remaining = remaining[(prefixEnd + 1)..];
        }

        var trailingStart = remaining.IndexOf(" :");
        if (trailingStart >= 0)
        {
            trailing = remaining[(trailingStart + 2)..].ToString();
            remaining = remaining[..trailingStart];
        }

        var parts = remaining
            .ToString()
            .Split(' ', StringSplitOptions.RemoveEmptyEntries);

        if (parts.Length == 0)
        {
            return new IrcMessage(
                Prefix: prefix,
                Command: string.Empty,
                Parameters: Array.Empty<string>(),
                Trailing: trailing);
        }

        var command = parts[0];
        var parameters = parts.Skip(1).ToArray();

        return new IrcMessage(
            Prefix: prefix,
            Command: command,
            Parameters: parameters,
            Trailing: trailing);
    }
}

This parser is intentionally small. It’s good enough for basic messages, but it’s not a full IRC implementation. That’s part of the lesson. Parsing the happy path is easy. Protocol parsing gets harder when you account for malformed input, odd edge cases, length limits, encoding, missing parameters, unexpected commands, and network behaviour. A toy parser is useful because it shows the shape of the problem. A production parser has to survive everything else.

Why PING and PONG matter

The most important command in this tiny client is probably PING. The server sends a line like this:

PING :server-token

The client must respond with:

PONG :server-token

This is a heartbeat. The server is checking whether the client is still alive. If the client doesn’t answer, the server can close the connection. That’s not unique to IRC. Long running network systems often need some form of liveness check. WebSockets have ping and pong frames. Message brokers have heartbeats. Databases and caches have connection keep-alive behaviour. Distributed systems constantly need to decide whether something is still there or just silent.

The code looks tiny:

if (message.Command.Equals("PING", StringComparison.OrdinalIgnoreCase))
{
    var token = message.Trailing ?? message.Parameters.FirstOrDefault();

    if (!string.IsNullOrWhiteSpace(token))
    {
        await SendAsync($"PONG :{token}");
    }
}

But the idea is bigger than IRC. When the connection stays open, you need to prove you’re still alive.

The connection is state

With a web API, a lot of state is pushed to the edges. The client sends a request. The server handles it. The response goes back. Then the next request starts again. IRC doesn’t feel like that. Once the client connects, it has connection state. It has a nickname. It may have joined channels. It may need to know whether registration has completed. It needs to read messages in order. It needs to handle server heartbeats. It needs to notice when the connection drops.

The lifecycle looks more like this:

That’s a useful mental shift. A long-running connection is not just a transport. It’s a little state machine. Once you see that, other systems become easier to reason about. SignalR clients, WebSocket consumers, queue consumers, database listeners, streaming APIs, and pub/sub clients all have similar concerns. The protocol changes, but the shape is familiar.

Reading from a stream is not the same as reading messages

This version uses StreamReader.ReadLineAsync(), which makes IRC convenient because IRC is line-based. That’s a luxury. Many protocols are not line based. Some use length prefixed frames. Some use binary headers. Some use chunks. Some allow partial messages across multiple reads. TCP itself doesn’t preserve your application message boundaries. If you send three messages, the receiver may not read three messages. It may read half of the first one. It may read one and a half. It may read all three together. IRC avoids some of that pain because lines give us a simple boundary. Even then, the application still has to decide what to do with each line. That’s why protocol design spends so much time on framing. You need a reliable way to know where one message ends and the next begins. In IRC, the frame is a line ending. In other protocols, the frame could be a length prefix, a delimiter, a fixed-size header, or a binary structure. The high-level lesson is simple, the stream doesn’t know your protocol. Your parser does.

What ASP.NET Core normally hides

This little client is a good reminder of how much a framework does for you. ASP.NET Core accepts connections. Kestrel parses HTTP. It handles headers, request bodies, content length, chunking, TLS, timeouts, limits, logging integration, cancellation, response writing, connection reuse, and plenty of awkward edge cases you probably don’t want to reimplement. That doesn’t mean you need to know every internal detail before building APIs. You don’t.

But it does help to understand the shape of the work being done underneath. When a production issue involves timeouts, stuck connections, slow clients, buffering, streaming, cancellation, or malformed input, the abstraction starts to leak. Knowing what a socket, stream, parser, and heartbeat look like makes those issues less mysterious.

Where this toy client breaks

The client above is intentionally incomplete. It doesn’t handle nickname conflicts. If the nickname is already taken, the server will send an error and the client won’t recover. It doesn’t wait for registration to complete before joining a channel. Some servers may tolerate that. A stricter client should wait for the welcome response. It doesn’t reconnect after disconnection. Real long-running clients need retry logic, backoff, and a clean way to rebuild state after reconnecting. It doesn’t rate limit outbound messages. IRC servers can disconnect clients that send too much too quickly. It doesn’t model channel membership, user lists, server capabilities, authentication, or message tags.

It also has a tiny parser. That’s fine for learning, but not enough for hostile or unusual input. This is where a small project becomes a real piece of software. The first version is a connection and a loop. The production version is lifecycle management, error handling, protocol coverage, and observability. That jump is the point of the exercise.

What I’d improve next

The first improvement would be separating the read loop from the write path. Right now, the client reads a line, handles it, and writes if needed. For a toy client, that’s fine. For a more capable client, you’d probably have an outbound channel for messages. The rest of the application would write commands into that channel, and one dedicated writer would send them to the server.

The shape would look like this:

That design avoids multiple parts of the application writing to the stream at the same time. It also gives you a natural place for rate limiting, logging, retries, and shutdown behaviour.

The next improvement would be proper connection state. You’d model the client as disconnected, connecting, registering, connected, and disconnecting. That sounds like ceremony, but it gives you somewhere to put behaviour. Then I’d add reconnects with backoff. After that, I’d add structured logging and counters for received messages, sent messages, reconnects, parse failures, and heartbeat responses. By that point, the tiny IRC client has turned into something that looks a lot like any other long-running integration client.

Why this is useful

A tiny IRC client won’t make your normal web APIs faster. It will make some production behaviour easier to understand. When you’ve written a client like this, long-running connections feel less magical. You’ve seen the read loop. You’ve seen the heartbeat. You’ve seen protocol parsing. You’ve seen the difference between connecting and being ready. You’ve seen why cancellation and shutdown need thought. That knowledge carries over.

It helps when working with WebSockets. It helps when reading from queues. It helps when dealing with streaming APIs. It helps when a service has a background connection to some external system and occasionally gets stuck, disconnected, or out of sync.

You don’t need to build your own protocol stack every day. But it’s useful to know what one looks like. I like small low level projects because they make familiar abstractions feel earned. ASP.NET Core is easier to appreciate when you’ve manually read from a socket. SignalR makes more sense when you’ve handled a heartbeat yourself. A message consumer feels less mysterious when you’ve written a loop that reads, parses, handles, and keeps going.

Building a tiny IRC client in C# is not about IRC taking over the world again. It’s about remembering that networked applications are built on streams, protocols, state, timeouts, and failure. The frameworks are valuable because they hide most of that most of the time. But when production gets weird, it helps to know what’s underneath.

https://innovation.world/irc-channels-for-engineering/