Library / SDK
karlseguin/websocket.zig avatar
karlseguin/websocket.zig

websocket.zig: a WebSocket implementation that gets out of your way in Zig

A websocket implementation for zig

523 stars93 forksZigMIT

At a glance

What is it?
websocket.zig is a Zig package that implements the WebSocket protocol for server and client use, built around a handler type you define and a small set of hooks. Its distinguishing feature is how little it assumes: the library validates the handshake, hands you the request, and otherwise leaves policy and payload handling to you.
Who is it for?
websocket.zig suits a Zig developer who needs a WebSocket server or client without pulling in a framework, and who wants to make the authentication and payload decisions in application code rather than configure them, and it does not suit someone who needs a batteries included realtime framework with rooms, presence and reconnection handled for them.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 19 days ago.
What is it written in?
Mainly Zig, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A handler struct and two methods

The core of the library is small enough to describe in one paragraph, and that is the design.

You create a websocket.Server(Handler), and the Handler is your own structure. It must have a public init function and a public clientMessage method. Other methods, such as close, can optionally be defined.

The init method is called with a *websocket.Handshake, a *websocket.Conn, and whatever application specific value you passed into Server(H).init. The clientMessage method is called whenever a text or binary message is received.

That is the whole contract, and it is a good one. The library owns the protocol, the framing, the masking and the socket; you own the application. There is no message routing framework, no room abstraction and no session store to learn.

The readme is explicit about the timing of init, and the reason matters. When init is called, the handshake response has not yet been sent to the client. That window is deliberate, because it allows your init to return an error, which causes websocket.zig to send an error response and close the connection. So you should not use or write to the *websocket.Conn at that point. Instead you use the afterInit method, which is called after the handshake response has been sent and is the first time the connection can safely be used.

afterInit supports two overloads.

zig
pub fn afterInit(handler: *Handler) !void
pub fn afterInit(handler: *Handler, ctx: anytype) !void

The ctx is the same ctx passed into init, and it is offered there for cases where the value is only needed once when the connection is established.

The application specific value is passed to server.listen, and the readme notes that you pass void into listen if you have none.

The handshake is yours to reject

The library validates the headers the WebSocket specification requires, and then hands the rest of the request to you. That division is the part worth understanding.

The readme gives the reason: applications may have additional requirements before allowing the connection to be upgraded, and a one time use token in the querystring is the example given. Applications should use the provided websocket.Handshake to apply any application specific verification and optionally return an error to terminate the connection.

The handshake exposes three fields. The url is the URL of the request in its original casing, the method is the method of the request in its original casing, and raw_headers holds the raw key and value headers with the keys lowercased.

To read individual headers you set the max_headers configuration value above zero, and then use req.headers.get("HEADER_NAME"). To set headers on the response you set max_res_headers above zero.

zig
pub fn init(h: *ws.Handshake, conn: *ws.Conn, app: *App) !Handler {
    h.res_headers.add("set-cookie", "delicious")
    //...
}

The readme flags a sharp edge here. The total length of the headers added to res_headers should not exceed 1024 characters, otherwise you will experience an out of bounds segfault. That is a real bug in the current implementation rather than a documented limit, and it is worth knowing before you start setting cookies on every connection.

The token check pattern that the readme shows is the common case.

zig
pub fn init(h: *websocket.Handshake, conn: websocket.Conn, app: *App) !Handler {
    // get returns a ?[]const u8
    // the name is lowercase
    // the value is in its original case
    const token = handshake.headers.get("authorization") orelse {
        return error.NotAuthorized;
    };

    return .{
        .app = app,
        .conn = conn,
    };
}

The headers are also iterable.

zig
var it = handshake.headers.iterator();
while (it.next) |kv| {
    std.debug.print("{s} = {s}\n", .{kv.key, kv.value});
}

And there is a memory rule to respect. Memory referenced by the websocket.Handshake, including headers from handshake.headers, is freed after the call to init completes, so an application that needs those values to outlive the call must make a copy.

Four shapes for clientMessage, and one deliberate omission

The clientMessage method can take one of four shapes, and the variations exist for two different reasons: whether you care about the message type, and whether you need to allocate.

The simplest is a handler and a byte slice.

zig
// simple clientMessage
clientMessage(h: *Handler, data: []u8) !void

The second takes a message type so you can tell text from binary.

zig
// clientMessage that accepts a tpe to differentiate between messages
// sent as `text` vs those sent as `binary`. Either way, Websocket.zig
// does not validate that text data is valid UTF-8.
clientMessage(h: *Handler, data: []u8, tpe: ws.MessageTextType) !void

The readme explains the omission behind that comment. The WebSocket specification has a distinct message type for text and binary, and text messages must be valid UTF-8. Websocket.zig does not perform this validation because it is expensive and most applications do not care. If you do care, you take the extra parameter and check the type yourself.

That is a defensible choice and it has a consequence. The library will hand you a text frame containing invalid UTF-8 without complaint, and the failure will surface as a decoding error in your own code, or worse, as a rendering bug in a client.

The third and fourth shapes add an allocator.

zig
// clientMessage that takes an allocator
clientMessage(h: *Handler, allocator: Allocator, data: []u8) !void

// cilentMessage that takes an allocator AND a MessageTextType
clientMessage(h: *Handler, allocator: Allocator, da

The allocator is a fast thread local buffer that falls back to an arena allocator, and allocations made with it are freed after clientMessage returns. That is a specific and useful design: you get a scratch space for the duration of the call without having to think about lifetimes, and anything you want to keep you copy out.

So the four shapes are the simple one, the typed one, the allocating one and the allocating and typed one. The choice is entirely about what your handler needs, and none of them is the default in a way that forces a decision on you.

How the tests are run, and what the Autobahn scripts are for

The Makefile shows how the project validates itself, and the two build configurations are the interesting part.

makefile
F=
zig ?= zig
.PHONY: t
t:
	TEST_FILTER='${F}' '${zig}' build test -Dforce_blocking=false -freference-trace --summary all
	TEST_FILTER='${F}' '${zig}' build test -Dforce_blocking=true -freference-trace --summary all

The t target runs the whole suite twice, once with force_blocking false and once with true, and both passes carry the reference trace flag and a full summary. Running everything twice with different blocking behaviour is a way to catch code that depends on an event loop completing in a particular order, which is exactly the class of bug that a WebSocket implementation is prone to.

There are then narrower targets: tn runs only the non blocking pass, tb runs only the blocking pass, and both accept a filter through the F variable, which is set from the TEST_FILTER environment variable inside the recipe. A developer working on one file can run the suite once with a filter instead of twice without.

The last two targets are the conformance checks.

makefile
.PHONY: abs
abs:
	bash support/autobahn/server/run.sh

.PHONY: abc
abc:
	bash support/autobahn/client/run.sh

The Autobahn test suite is the industry standard set of WebSocket protocol conformance tests, and the support/ directory in the repository holds a server script and a client script. Running them as separate targets is the sensible arrangement, since the suite tests a server against a reference client and a client against a reference server.

There is also a Dockerfile at the root, and it is not what you would expect. Rather than building the library, it fetches a specific Zig development build for aarch64 Linux, extracts it, and sets the entrypoint to run the test suite.

dockerfile
from ubuntu:latest
run apt-get update && apt-get install -y wget xz-utils

run wget "https://ziglang.org/builds/zig-linux-aarch64-0.14.0-dev.244+0d79aa017.tar.xz"

So the container is a portable test environment rather than a distribution image. Note the version it pins is a 0.14.0 development build while the readme says the library is for Zig 0.17.0, which tells you the container has not been updated in step with the library and that the branches in the repository are what you should follow for version matching.

Where websocket.zig is the wrong tool

There are four cases where this is the wrong choice, and one where it is clearly the right one.

The first is a project that wants a full realtime framework. websocket.zig gives you the protocol and a handler. It does not give you rooms, presence, a pub/sub layer, automatic reconnection, backpressure, or a client that reconnects for you. The readme's own framing, a WebSocket implementation for Zig, is accurate, and an application that needs those features is looking for something else.

The second is a project where the language is not Zig. The library is written in Zig and exposes a Zig API, including Zig specific things like the anytype overload of afterInit and the allocator parameter. There is no binding here, so a Python or Rust service that needs WebSockets is not going to use this.

The third is a team that cannot absorb a fast moving library. The readme says this is for Zig 0.17.0 and points to separate branches for other versions, and it tells upgraders from a previous version to check the Server Migration and Client Migration wiki pages. A library that requires you to read a migration guide between versions, on a language that is itself still changing, is a real cost for a production service. The repository has no GitHub releases, and the last push was on 2026-10-04, so there is no tagged version to pin either.

The fourth is anything that needs the client side to be forgiving. The library deliberately does not validate that text frames are valid UTF-8, and it exposes the raw header bytes with a 1024 character limit on response headers that currently produces a segfault when exceeded. Those are reasonable choices for a library that stays out of your way, and they are places where a consumer has to do the work.

The right case is a Zig service that needs WebSockets and wants to own its protocol decisions. The handshake hook is the strongest argument. Instead of configuring an authentication scheme, you read the request, check whatever you want, and return an error to close the connection before the upgrade completes. The four clientMessage shapes let you keep the handler trivial until you need memory or type information, and the temporary allocator means a handler that needs to parse a frame can do so without managing lifetimes.

On the alternative, the difference in approach against a full framework is where the policy lives. A framework makes the decisions for you, and you configure them. This library makes almost no decisions, and you write the code that would otherwise be configuration. The trade is the ordinary one: less to learn and less to configure, and more code for you to write and keep correct. For a Zig developer who wants a transport and not an application framework, that is the right way round.

The licence is MIT, recorded in the LICENSE file at the repository root, and the package is consumed through the standard build.zig and build.zig.zon files.

Editorial conclusion

websocket.zig suits a Zig developer who needs a WebSocket server or client without pulling in a framework, and who wants to make the authentication and payload decisions in application code rather than configure them, and it does not suit someone who needs a batteries included realtime framework with rooms, presence and reconnection handled for them. Before you adopt it, confirm the Zig version on your branch matches the one you build with, since the readme says this is for Zig 0.17.0 and points to separate branches for other versions, and read the Server Migration and Client Migration wiki pages if you are coming from an earlier release, because the readme says the API changed.

Frequently asked questions

What is websocket.zig?

It is a WebSocket implementation for Zig, published as a package with build.zig and build.zig.zon. You define a Handler struct with an init function and a clientMessage method, and the library handles the protocol while your code handles the application.

Which Zig version does websocket.zig support?

The readme says this is for Zig 0.17.0, and points to separate branches for other versions. If you are upgrading from a previous release, the readme directs you to the Server Migration and Client Migration wiki pages.

How do I reject a websocket connection during the handshake?

Your init method receives a *websocket.Handshake and can return an error, which causes websocket.zig to send an error response and close the connection before the upgrade completes. The handshake exposes the url, method and raw headers, and header lookup is available when max_headers is set above zero.

Does websocket.zig validate that text messages are valid UTF-8?

No. The readme says websocket.zig does not do this validation because it is expensive and most apps do not care. You can request a MessageTextType parameter on clientMessage if you need to distinguish text from binary yourself.

What licence is websocket.zig released under?

It is released under the MIT License, with the LICENSE file at the repository root.

Official sources

  1. Issues
  2. karlseguin/websocket.zig on GitHub
  3. License: MIT
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/karlseguin-websocket-zig.svg)](https://hysenlabs.com/projects/karlseguin-websocket-zig)

Was this useful?