C# Programming
Client-Server Architecture
File Upload/Download
Single Threading
Network Programming

A single threaded client/server in C# with file upload, download

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

A single-threaded client and server in C# can handle file upload and download perfectly well if the protocol is simple and the workload is modest. The key tradeoff is that the server processes one client at a time, so design clarity matters more than concurrency tricks. For learning, internal tools, or controlled environments, a single-threaded model is often the right place to start.

Keep the Protocol Explicit

The easiest mistake in file transfer code is sending raw bytes without a clear message boundary. Even a simple server should define a tiny protocol so both sides agree on the command, file name, and content length.

A practical text-first protocol is:

  • 'UPLOAD|filename|length'
  • 'DOWNLOAD|filename'
  • raw file bytes follow only after the upload header is acknowledged

That is enough to support the two core operations without inventing a full binary framing format.

A Simple Single-Threaded Server

The server below accepts one connection, reads one command, handles it fully, closes the client, and then waits for the next connection. That is the essence of single-threaded behavior.

csharp
1using System;
2using System.IO;
3using System.Net;
4using System.Net.Sockets;
5using System.Text;
6
7public static class FileServer
8{
9    public static void Run(string rootFolder, int port)
10    {
11        Directory.CreateDirectory(rootFolder);
12        var listener = new TcpListener(IPAddress.Loopback, port);
13        listener.Start();
14
15        Console.WriteLine($"Listening on port {port}");
16
17        while (true)
18        {
19            using TcpClient client = listener.AcceptTcpClient();
20            using NetworkStream stream = client.GetStream();
21            using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
22            using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true) { AutoFlush = true };
23
24            string? header = reader.ReadLine();
25            if (string.IsNullOrWhiteSpace(header))
26            {
27                continue;
28            }
29
30            string[] parts = header.Split('|');
31            if (parts[0] == "UPLOAD" && parts.Length == 3)
32            {
33                string fileName = Path.GetFileName(parts[1]);
34                int length = int.Parse(parts[2]);
35                string fullPath = Path.Combine(rootFolder, fileName);
36
37                writer.WriteLine("READY");
38
39                using FileStream output = File.Create(fullPath);
40                CopyBytes(stream, output, length);
41                writer.WriteLine("OK");
42            }
43            else if (parts[0] == "DOWNLOAD" && parts.Length == 2)
44            {
45                string fileName = Path.GetFileName(parts[1]);
46                string fullPath = Path.Combine(rootFolder, fileName);
47
48                if (!File.Exists(fullPath))
49                {
50                    writer.WriteLine("ERROR|NOT_FOUND");
51                    continue;
52                }
53
54                byte[] bytes = File.ReadAllBytes(fullPath);
55                writer.WriteLine($"OK|{bytes.Length}");
56                stream.Write(bytes, 0, bytes.Length);
57            }
58            else
59            {
60                writer.WriteLine("ERROR|BAD_COMMAND");
61            }
62        }
63    }
64
65    private static void CopyBytes(Stream input, Stream output, int bytesToCopy)
66    {
67        byte[] buffer = new byte[8192];
68        int remaining = bytesToCopy;
69
70        while (remaining > 0)
71        {
72            int read = input.Read(buffer, 0, Math.Min(buffer.Length, remaining));
73            if (read == 0)
74            {
75                throw new EndOfStreamException("Client disconnected during upload.");
76            }
77
78            output.Write(buffer, 0, read);
79            remaining -= read;
80        }
81    }
82}

The important design choice is that the server finishes one request before accepting the next. That makes the control flow easy to reason about.

Matching Client Logic

The client must speak the same protocol. For uploads, it sends the header, waits for READY, then streams the file bytes. For downloads, it sends the request, reads the response header, and saves the incoming bytes if the server confirms success.

csharp
1using System;
2using System.IO;
3using System.Net.Sockets;
4using System.Text;
5
6public static class FileClient
7{
8    public static void Upload(string host, int port, string path)
9    {
10        using TcpClient client = new TcpClient(host, port);
11        using NetworkStream stream = client.GetStream();
12        using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
13        using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true) { AutoFlush = true };
14
15        byte[] data = File.ReadAllBytes(path);
16        writer.WriteLine($"UPLOAD|{Path.GetFileName(path)}|{data.Length}");
17
18        if (reader.ReadLine() != "READY")
19        {
20            throw new InvalidOperationException("Server refused upload.");
21        }
22
23        stream.Write(data, 0, data.Length);
24        Console.WriteLine(reader.ReadLine());
25    }
26}

This stays single-threaded on both sides, but it is still a real upload/download design because the protocol boundaries are explicit.

Why Single-Threaded Can Still Be Useful

A single-threaded server is not automatically “wrong.” It is often a reasonable design when:

  • only one client is expected at a time
  • file sizes are moderate
  • the goal is simplicity or teaching
  • the server runs in a controlled internal environment

The limitation is throughput. One slow client blocks the next client from being served. That is acceptable only if the workload can tolerate it.

Security and Validation Still Matter

Even a teaching example should validate file names and restrict access to a safe root directory. The call to Path.GetFileName in the sample is there for a reason: it strips path traversal attempts such as directory separators embedded in the request.

You should also limit file size and think about what happens when a client disconnects mid-transfer. Single-threaded code is simpler than concurrent code, but it still needs protocol discipline.

Common Pitfalls

  • Sending file bytes without a length or other framing rule, which makes message boundaries ambiguous.
  • Assuming TCP preserves your application-level message chunks exactly as you wrote them.
  • Forgetting that a single-threaded server blocks every other client while one transfer is in progress.
  • Accepting raw client-supplied paths and accidentally allowing writes outside the intended storage folder.
  • Treating a demo protocol as production-ready without adding authentication, limits, and error recovery.

Summary

  • A single-threaded C# file server can be valid when simplicity matters more than concurrency.
  • The protocol should make commands and byte lengths explicit.
  • 'TcpListener, TcpClient, and NetworkStream are enough for a small upload/download design.'
  • One client at a time is easy to reason about but limits throughput.
  • Clear framing and file-path validation matter even in simple examples.

Course illustration
Course illustration

All Rights Reserved.