Site logo

Simple Web Server in Swift

June, 2024

I’ve been wanting to improve the built-in web server of the static site generator I use for these articles, since I need to stop and restart the server any time I want to see my latest changes. It would be really nice to have the server hot reload files so I just have to refresh my browser to see the latest edits. Surely it should be easy to find a nice Swift package for a basic web server?

One of the first candidates that I came across was Swifter which bills itself as a “tiny http server engine written in Swift”. That sounds like a promising start! Hmm, it’s well over 2000 lines of code... we have different ideas of “tiny” 😅

The code is pretty clean and understandable though and it definitely does what I want, so maybe I can strip it down to only what I need:

  1. Run only on localhost
  2. Support only GET requests
  3. Serve image files straight from disk
  4. Serve generated HTML files

I definitely didn’t need POST requests, multi-part form decoding or WebSockets support, so that was easy enough to remove. But then most of what I had left was code like this:

var addr = sockaddr_in6(
    sin6_len: UInt8(MemoryLayout<sockaddr_in6>.stride),
    sin6_family: UInt8(AF_INET6),
    sin6_port: port.bigEndian,
    sin6_flowinfo: 0,
    sin6_addr: in6addr_any,
    sin6_scope_id: 0

...and that’s when my eyes started glazing over 😵‍💫. This looks like C code I was writing last century! Surely opening a network socket using a modern language is easier than this in the year 2024. Apple has really nice APIs for talking to a HTTP server, so there must be something I can use for building a server. Time for some more research.

Searching the SDK docs, I came across SocketPort which looks like it should be able to replace some of the gnarlier C APIs. There’s also NSStream which looks like it can read and write to the socket. How do I bind the data streams to the socket though, do I really still need to drop down to file descriptors?

More searching turned up CFStreamCreatePairWithSocketToHost which sounds like just what I need. Yes ok it uses CoreFoundation so we’re going backwards a bit here, but I can live with that. Oh wait, it’s deprecated. With no replacement API suggested 😒

Hang on, I remember rewriting a network status monitor at work recently that used a modern class called NWPathMonitor. Turns out there is a modern framework for networking – helpfully called Network – I’d forgotten all about it. And it has a class called NWListener for handling incoming connections, perfect. Let’s get cracking.

HTTP messages

The format of basic HTTP messages is fairly straightforward, you can find what you need just from the Wikipedia entry for HTTP. A minimal HTTP request looks like this:

GET /some/path?param=foo HTTP/1.1

The first line contains the method (GET), the resource path and the HTTP version separated by spaces. Following this are the request headers which are colon-separated key/value pairs.

A response to this request can be as simple as:

HTTP/1.1 200 OK
Content-Length: 12

Hello world!

The first line is the protocol version, response status code and reason, again separated by spaces. Next are the response headers, a blank line, then any response body content.

Simple right? Let’s get a server running that serves just this response.

Handling connections

Looking at NWListener, it has a callback-based API for dealing with state changes and new connections:

import Network

final class Server {
    let listener: NWListener

    init(port: NWEndpoint.Port) {
        self.listener = try! NWListener(using: .tcp, on: port)

        listener.stateUpdateHandler = {
            print("listener.state = \($0)")

        listener.newConnectionHandler = handleConnection

        listener.start(queue: .main)

    func handleConnection(_ connection: NWConnection) {
        print("New connection!")

The port is just a number that identifies which service on a host you’re trying to talk to. For example, one host might have a web server, a mail server and an FTP all running at once, so they need to be on different ports. Every common protocol has a default port number (port 80 for HTTP) but for debug servers we normally pick some larger port number like 8000. That just means when you connect to your server you add the port number in your browser address bar like http://localhost:8000.

I created a new Swift package that builds a macOS command-line app, since I don’t need a UI for this server. In main.swift, start the server like this:

let server = Server(port: 8000)

The second line is so that the app stays running and listening for connections, instead of immediately exiting.

Let’s fill out that handleConnection function to actually open the connection:

    func handleConnection(_ connection: NWConnection) {
        print("New connection!")

        connection.stateUpdateHandler = {
            print("connection.state = \($0)")
        connection.start(queue: .main)

Since we’re just building a simple debug server, receiving events on the main queue is fine.

Start the app and run curl -v localhost:8000 in your terminal to make a request to it, and you should see this:

listener.state = ready
New connection!
connection.state = preparing
connection.state = ready

As soon as we have a connection, we can start reading the HTTP request data from it. Add this function and call it from the end of handleConnection:

    func receive(from connection: NWConnection) {
            minimumIncompleteLength: 1,
            maximumLength: connection.maximumDatagramSize
        ) { content, _, isComplete, error in

            if let error {
                print("Error: \(error)")
            } else if let content {
                print("Received request!")
                print(String(data: content, encoding: .utf8)!)

            if !isComplete {
                self.receive(from: connection)

This reads as much data as we can from the connection into content and prints it out. Since web browsers reuse connections for multiple requests (“keep alive”) we finish by waiting for the next request.

Sending a response

Finally, let’s actually send a response, by calling this after “Received request”:

    func respond(on connection: NWConnection) {
        let response = """
            HTTP/1.1 200 OK
            Content-Length: 12

            Hello world!

            content: .utf8),
            completion: .idempotent

You can actually load localhost:8000 in your web browser now and see the reply displayed.

Boom! A minimal web server in 50 lines of Swift 👊

Ok it’s not super useful as is, let’s add a couple of helper types to wrap the request and response messages.

Parsing HTTP requests

Here’s how we could parse the request:

struct Request {
    let method: String          // "GET"
    let path: String            // "/foo/bar"
    let httpVersion: String     // "HTTP/1.1"
    let headers: [String: String]

    init?(_ data: Data) {
        let str = String(data: data, encoding: .utf8)!
        let lines = str.components(separatedBy: "\r\n")
        guard let firstLine = lines.first,
              let lastLine = lines.last, lastLine.isEmpty else {
            return nil

        let parts = firstLine.components(separatedBy: " ")
        guard parts.count == 3 else {
            return nil

        self.method = parts[0]
        self.path = parts[1].removingPercentEncoding!
        self.httpVersion = parts[2]

        let headerPairs = lines.dropFirst()
            .map { $0.split(separator: ":", maxSplits: 1) }
            .filter { $0.count == 2 }
            .map { ($0[0].lowercased(), $0[1].trimmingCharacters(in: .whitespaces)) }

        self.headers = Dictionary(headerPairs, uniquingKeysWith: { old, _ in old })

This ensures that the message is in the correct format for a GET request; it assumes there is no request body. Some things to note:

  • Lines in the request should end with a carriage return and line feed, so this splits on \r\n
  • There must be a blank line at the end of the request
  • Header lines should only be split on the first colon, because the value can also contain colons (e.g. Host: localhost:8000)
  • Header keys are lowercased because they should be treated as case-insensitive
  • This is not production quality code! Those force-unwraps should be handled properly at least 😬

Building HTTP responses

Creating a valid response is not too tricky, it’s basically just outputting all the lines joined with CRLF:

struct Response {
    let httpVersion = "HTTP/1.1"
    let status: Int
    let reason: String
    let headers: [String: String]
    let body: Data

    var messageData: Data {
        let statusLine = "\(httpVersion) \(status) \(reason)"
        var lines = [statusLine]
        lines.append(contentsOf:{ "\($0.key): \($0.value)" }))
        lines.append("")    // adds extra required blank line
        let header = lines.joined(separator: "\r\n").data(using: .utf8)!

        return header + body

A response can now be created with:

    status: 200,
    reason: "OK",
    headers: ["Content-Length": "12"],
    body: "Hello world!".data(using: .utf8)!

There are plenty of improvements that could be made here, such as automatically determining the Content-Length or using enums of known status codes and header names.

MIME types

One feature that I needed was serving images, and for that I need to set a header that says what the type of file is, such as Content-Type: image/jpeg. That code is known as the MIME type of the file, and Swifter has a big map of known types.

We can do without that though, because macOS can figure out the MIME type for us. Using the UniformTypeIdentifiers framework and calling UTType.jpeg.preferredMIMEType will return “image/jpeg”. There’s even an API that will determine the type for any file.

I’m going to extend the Response type to make it easy to return images, plus throw in a couple of other improvements:

import UniformTypeIdentifiers

struct Response {
    let httpVersion = "HTTP/1.1"
    let status: Status
    let headers: [Header: String]
    let body: Data

    enum Status: Int, CustomStringConvertible {
        case ok = 200
        case notFound = 404

        var description: String {
            switch self {
                case .ok: return "OK"
                case .notFound: return "Not Found"

    enum Header: String {
        case contentLength = "Content-Length"
        case contentType = "Content-Type"

        _ status: Status = .ok,
        headers: [Header: String] = [:],
        body: Data = Data(),
        contentType: UTType? = nil
    ) {
        self.status = status
        self.body = body
        self.headers = headers.merging(
                .contentLength: String(body.count),
                .contentType: contentType?.preferredMIMEType,
            ].compactMapValues { $0 },
            uniquingKeysWith: { _, new in new }

    init(_ text: String, contentType: UTType = .plainText) {
        self.init(body: .utf8)!, contentType: contentType)

    init(file url: URL) throws {
        try self.init(
            body: Data(contentsOf: url),
            contentType: url.resourceValues(forKeys: [.contentTypeKey]).contentType

    var messageData: Data {
        let statusLine = "\(httpVersion) \(status.rawValue) \(status)"

        var lines = [statusLine]
        lines.append(contentsOf:{ "\($0.key.rawValue): \($0.value)" }))
        lines.append("")    // adds extra blank line
        let header = lines.joined(separator: "\r\n").data(using: .utf8)!

        return header + body

Now different types of HTTP responses can be easily constructed like so:

Response("Hello world")

Response("<html><body>Hi!</body></html>", contentType: .html)

try! Response(file: URL(filePath: "~/Desktop/somepic.jpg"))


There are of course loads more handy features you could build on top of this, like a router to map request paths to different responses. But that’s how you end up with 2000+ lines instead of 50 😆

It’s always nice to build something exactly how you want it from the ground up though, especially when you can use nice modern APIs!

The source code from this post can be found on Github. If you spot a bug, please create an issue on there.

Any comments or questions about this post? ✉️ nick @ this domain.

— Nick Randall

Tagged with: