A Go package for handling the HTTP/1.1 "101 Switching Protocols" handshake on both ends of a connection returning a net.Conn.
A common problem: you need a long-lived, bidirectional connection between a client and server, but your infrastructure speaks HTTP. Load balancers, reverse proxies, monitoring tooling, all of it is built around HTTP. Starting from scratch on a raw TCP port means fighting your own stack.
HTTP has a solution for this. It's called 101 Switching Protocols, and it's been in the spec since HTTP/1.1. A client sends an upgrade request; the server agrees; the connection is handed off as a raw socket. WebSocket uses it. So does HTTP/2. So can your protocol.
The question is what you reach for to implement it.
The trap
The obvious move is a WebSocket library, even if you don't want WebSocket semantics. The handshake is the same, the framing can be stripped away, and at least something handles the HTTP negotiation. Except now you're carrying a dependency that was designed for a different protocol, with abstractions that don't fit your model, updated on someone else's schedule.
The other move is http.Hijacker, Go's built-in escape hatch from the HTTP handler layer. It's the right instinct. But using it correctly is more precise than it looks: the 101 response has to be written by hand to the hijacked buffer, that buffer may already contain the first bytes of your protocol stream and must not be discarded, and the client side needs its own bufio-aware response parsing. None of this is hard, but it's the kind of thing that gets subtly wrong under pressure and is invisible in testing.
What Cooper does instead
Cooper is the implementation of that precision work, extracted once and made composable.
Server side. cooper.Hijack is an http.Handler. It validates the upgrade request, writes the 101 response, drains any buffered bytes back into the connection, and calls your handler with a clean net.Conn.
http.Handle("/stream", cooper.Hijack(func(conn net.Conn, proto string) {
defer conn.Close()
// conn is a raw net.Conn. Speak whatever protocol you want.
serveMyProtocol(conn)
}, cooper.Protocols("myproto/1")))
Client side. cooper.Dial establishes the connection and performs the upgrade in one call. You construct the request, set the headers, and get back a net.Conn.
req, _ := http.NewRequest("GET", "http://host:8080/stream", nil)
req.Header.Set("Upgrade", "myproto/1")
raw, err := cooper.Dial(req)
// raw is a net.Conn. The TCP connection and HTTP handshake are done.
For TLS, pass WithTLSConfig. The server name is derived from the request URL automatically.
req, _ := http.NewRequest("GET", "https://host:8443/stream", nil)
req.Header.Set("Upgrade", "myproto/1")
raw, err := cooper.Dial(req, cooper.WithTLSConfig(&tls.Config{}))
If you already have a connection — from a custom dialer, a test pipe, or a tls.Conn — cooper.Upgrade does the handshake over it directly.
conn, _ := net.Dial("tcp", "host:8080")
req, _ := http.NewRequest("GET", "http://host:8080/stream", nil)
req.Header.Set("Upgrade", "myproto/1")
raw, err := cooper.Upgrade(conn, req)
// raw is a net.Conn. The HTTP handshake is done.
Building WebSocket on top
WebSocket's handshake requires one additional step: a challenge-response exchange using Sec-WebSocket-Key and Sec-WebSocket-Accept. Cooper doesn't know what WebSocket is, but it provides the hooks.
On the server, extra headers are injected into the 101 response:
cooper.ResponseHeaders(func(r *http.Request, proto string) http.Header {
h := http.Header{}
h.Set("Sec-WebSocket-Accept", computeAccept(r.Header.Get("Sec-WebSocket-Key")))
return h
})
On the client, the response is validated before the connection is returned:
cooper.ResponseValidator(func(req *http.Request, resp *http.Response) error {
if resp.Header.Get("Sec-WebSocket-Accept") != computeExpected(req) {
return errors.New("accept header mismatch")
}
return nil
})
A complete WebSocket implementation needs exactly these two hooks from the handshake layer. Cooper provides them and nothing else. The framing, message parsing, and ping/pong logic live in your code, where they belong.
What was not built
Cooper has no protocol registry. No connection pool. No middleware chain. No generated code. The net.Conn it returns has no wrapper that changes how reads and writes behave the only exception is an internal prefixConn that prepends any bytes the HTTP layer had already buffered, which is transparent to the caller and exists purely to ensure nothing is lost at the handshake boundary.
The full implementation is seven files. Zero external dependencies. Every type in the public API is from the standard library.
The scope is deliberate. A package that does one thing and costs almost nothing to understand will still be working correctly long after a framework would have been replaced twice.
A fast, flexible Go package for serving static web assets with in-memory caching, on-the-fly minification, and virtual bundle support.
Every Go web application that serves static assets eventually makes the same discovery: http.FileServer is correct, but it is not enough. It reads from disk on every request. It does not minify. It has no concept of a bundle — a composed artifact that your application treats as a single file but your source tree stores as several. And so you reach for something bigger.
The options are mostly the same: a full asset pipeline with a configuration format, a middleware from a framework you weren't planning to use, or a Node.js build step that somehow ends up as a permanent fixture in your deployment. Each one solves the immediate problem and brings a new layer you now have to understand, update, and justify to the next person who reads the code.
The underlying needs are not complicated. Read a file once, serve it from memory after that. If it's JavaScript or CSS, make it smaller first. If several source files belong together logically, let them be requested as one.
What webassets does
webassets.New returns an http.Handler. That is the entire public surface that matters.
h := webassets.New("./web/static",
webassets.WithCache(true),
webassets.WithMinification(true),
)
http.Handle("/assets/", http.StripPrefix("/assets", h))
On the first request for a file, it is read from disk, optionally minified, and stored in memory. Every subsequent request gets the in-memory copy. The work of reading and minifying each file happens exactly once, even under concurrent load — a singleflight guard ensures that a hundred simultaneous cold-cache requests for the same file result in one disk read, not a hundred.
When caching is disabled, the handler delegates entirely to http.FileServer. You get all the standard HTTP semantics — ETags, If-Modified-Since, Range requests — without reimplementing them.
Bundles
A bundle is a virtual file. It does not exist on disk; it is assembled at request time from the source files you name.
webassets.WithBundle("app.bundle.js", "js/vendor.js", "js/app.js")
A request for /assets/app.bundle.js reads js/vendor.js and js/app.js in order, concatenates them, runs the result through the minifier if enabled, and caches it. The caller sees a single response. The source files stay separate on disk and in version control, where they belong.
Exclusions and protected files
Some paths should never be cached — admin panels, dynamically generated manifests, anything where stale data is worse than a disk read. WithCacheExclude removes those paths from the cache without disabling it everywhere else.
webassets.WithCacheExclude("/assets/manifest.json")
Files whose names begin with _ are not served at all. A 403 is returned regardless of whether the cache is on or off. This is a convention, not a configuration option: if a file starts with _, it is not a public asset.
Error visibility
The handler always recovers from errors and returns a safe HTTP response. A missing file is a 404. A bundle that fails to build is a 500. Nothing panics. If you want to know when these things happen — and in production you probably do — you provide a function:
webassets.WithErrorLogger(func(err error) {
slog.Error("webassets", "err", err)
})
What was not built
There is no asset fingerprinting. No content hash appended to filenames for cache-busting. No HTTP/2 server push hints. No live reload for development. No configuration file. No CLI. No concept of environments.
Some of these may be worth building. None of them belong here. The scope of this package is the boundary between your HTTP handler and the files you want to serve — not the build pipeline that produces those files, not the CDN that fronts them, not the browser cache that holds them. Staying inside that boundary is what keeps the package auditable and its behavior unsurprising.
One external dependency ships: tdewolff/minify, vendored. Minification is not something the standard library provides, the implementation is not trivial to get right across the range of valid JavaScript and CSS, and the cost of the dependency is lower than the cost of owning the parser. It is vendored so that the version you ship is the version you chose, not whatever go get resolves to on the next clean build.
The singleflight implementation is local — forty lines of sync.Mutex and sync.WaitGroup. The standard library already provides everything it needs.
A standalone, zero-external-dependency Go package for managing background jobs and scheduled tasks.
Every application eventually grows a background layer. Cache warming. Nightly cleanup. Email delivery. Report generation. It starts as a single goroutine launched in main, then a ticker, then two tickers, then a sync.WaitGroup that someone added to make shutdown less violent. None of it is wrong, exactly. But none of it is the thing you actually wanted to build, and now it lives in your application code, tangled up with the rest of it.
At some point someone reaches for a library. That is where the trouble usually starts.
The trap
The Go ecosystem has several background-job libraries. Most of them share a shape: a central scheduler, a registry of named jobs, middleware hooks, retry policies, priority queues, admin dashboards behind a flag nobody reads. They are frameworks in the sense that the framework is the primary concern and your jobs are a detail it manages.
The tradeoff is not just API surface. It is control. When the library owns the scheduler loop, owns the worker pool, owns the logging — you are adapting your application to its model, not the other way around. When something breaks at midnight, you are reading someone else's source code to understand what is happening to your production traffic.
There is also the external cron library, reached for because parsing "*/5 * * * *" looks tedious. It isn't. A 5-field cron parser is a few dozen lines of straightforward code. Importing a library for it means subscribing permanently to someone else's release cycle for a problem that is already solved.
What backstage does instead
backstage is a supervisor. It manages named worker pools and a scheduler. You register queues. You dispatch jobs. You schedule recurring tasks. You call Serve and hand it a context. When the context is cancelled, it drains in-flight work and returns.
That is the entire model.
Worker pools. A queue is a named pool of goroutines backed by a Store. The default store is an in-memory buffered channel. Dispatch is non-blocking — if the store cannot accept the job, you get an error back immediately and decide what to do with it.
sv := backstage.New("myapp")
sv.RegisterQueue("emails", backstage.QueueConfig{
Workers: 3,
Buffer: 100,
})
if err := sv.Dispatch("emails", backstage.Job{
Name: "send-welcome",
Run: sendWelcomeEmail,
}); err != nil {
// errors.Is(err, backstage.ErrQueueFull) — handle in your code
}
Schedules. Three built-in implementations cover the realistic surface: a fixed interval, a daily wall-clock time, and a standard 5-field cron expression. The cron parser is written in-house. No external dependency, no import of a library that exists solely to parse a string format that has not changed in forty years.
// Every 15 minutes, measured from when the task fires.
sv.Schedule(backstage.Every(15*time.Minute), backstage.Job{
Name: "refresh-cache",
Run: refreshCache,
})
// Daily at 03:00 UTC.
sv.ScheduleOnQueue("reports", backstage.MustCron("0 3 * * *"), backstage.Job{
Name: "nightly-report",
Run: generateReport,
})
Scheduled tasks can run directly in their own goroutine, or they can be dispatched onto a named queue — so concurrency is controlled by the queue's worker count, not by how many timers fire at once.
Retries. Each job carries its own retry policy: a count and an optional delay between attempts. A job that panics is never retried — a panic is a programmer error, not a transient failure. If the context is cancelled during a retry delay, the job stops without further attempts.
sv.Dispatch("emails", backstage.Job{
ID: "welcome-" + userID, // optional; used by stores for deduplication
Name: "send-welcome",
Retries: 3,
RetryDelay: 5 * time.Second,
Run: sendWelcomeEmail,
})
The ID field is optional. When set it appears in every log record for that execution, and custom store implementations receive the full Job value on Push — so deduplication and idempotency checks live where they belong: in the store, close to the persistence layer.
Graceful shutdown. Serve blocks until the context is cancelled, then waits up to a configurable drain timeout for in-flight workers to finish. Workers receive the same context, so long-running jobs can respect cancellation.
sv.Serve(ctx) // blocks; returns nil after draining
The Store interface
By default, jobs live in memory. A process restart loses them. For most background work — cache warming, cleanup sweeps, notification fanout — that is acceptable. For work that must not be lost, the queue's backing store is an interface:
type Store interface {
Push(job Job) error
Pop(ctx context.Context) (Job, error)
Len() int
}
Implement those three methods — backed by a database table, a Redis list, whatever fits your system — and pass it in:
sv.RegisterQueue("billing", backstage.QueueConfig{
Workers: 2,
Store: mydb.NewJobStore(db, "billing"),
})
The supervisor, scheduler, and workers are unchanged. The durability guarantee moves into your store implementation, where you control it, where you can test it, where it does not depend on how backstage evolves.
What was not built
There is no priority queue. No dead-letter queue. No admin interface. No job status tracking, no history. No middleware chain. No plugin system.
All of those are real problems. They are also problems with correct answers that vary by application — a retry policy for an email queue is different from one for a billing event, and both should live in the code that understands the domain, not in a generic library that can only approximate it.
backstage handles the mechanics: goroutines, lifecycles, scheduling, context propagation, structured logging. Everything above that layer is yours.
The full implementation is nine files. Zero external dependencies. The Schedule interface is a single method. The Store interface is three. Every public type is composable with the standard library and nothing else.
A minimal Go template engine built on `html/template`. Layout wrapping, reusable partials, and per-render overrides.
Every Go web project eventually needs the same things: a layout that wraps each page, reusable partials for nav bars and alerts, and templates that reload in development but ship compiled in production. Go's html/template handles the rendering. It has no opinion on any of the rest.
That gap is where every Go web project eventually writes the same boilerplate.
The trap
The first instinct is to reach for a third-party template engine. Most of them introduce their own syntax, their own escaping rules, and their own lifecycle, none of which you asked for. You wanted layouts and partials; you got a framework.
The second instinct is to stay with html/template and wire it up yourself. It works. But the setup is the kind of thing that gets quietly reinvented on every project: layout composition, a partial cache, the filesystem toggle between development and production. None of it is hard. It just never feels worth extracting, until you've written it a third time.
What Stencil does instead
Stencil is that wiring, extracted once and made reusable.
Layouts. A layout is an ordinary html/template file with a single {{ yield }} call where the page content should appear. Pages render inside it by default.
<!-- layouts/base.html -->
<html>
<body>{{ yield }}</body>
</html>
engine := stencil.New(stencil.Config{
Views: views,
Layouts: layouts,
Partials: partials,
DefaultLayout: "base",
})
engine.Render(w, "home", data)
Partials. Any partial can be rendered from inside a template with {{ partial "name" . }}. The partial is parsed on first use and cached for the lifetime of the engine — the parse cost is paid once.
{{ partial "nav" . }}
{{ partial "alert" "Something went wrong" }}
Per-render overrides. A single call can use a different layout or skip the layout entirely — useful for HTMX partial responses.
// Use a different layout for this one page
engine.Render(w, "about", data, stencil.WithLayout("minimal"))
// Return just the fragment, no wrapping layout
engine.Render(w, "table-rows", data, stencil.WithoutLayout())
The dev/prod split
Stencil accepts fs.FS for each template directory. The engine itself does not distinguish between a live filesystem and an embedded one — that decision belongs to the caller.
For development, pass os.DirFS. View and layout templates are read from disk on every render; changing a file takes effect immediately. Partials are cached by default — set DisableCache: true to re-parse them on every render as well, so no manual cache invalidation is needed.
engine := stencil.New(stencil.Config{
Views: os.DirFS("views"),
Layouts: os.DirFS("layouts"),
Partials: os.DirFS("partials"),
DefaultLayout: "base",
DisableCache: true,
})
For production, embed the directories and pass the sub-filesystems. Templates are compiled into the binary; no disk I/O at runtime.
//go:embed views layouts partials
var templateFS embed.FS
views, _ := fs.Sub(templateFS, "views")
layouts, _ := fs.Sub(templateFS, "layouts")
partials, _ := fs.Sub(templateFS, "partials")
engine := stencil.New(stencil.Config{
Views: views,
Layouts: layouts,
Partials: partials,
DefaultLayout: "base",
})
The same engine code runs in both environments. The only difference is what gets passed in at startup.
What was not built
Stencil has no template inheritance hierarchy. No slot system. No hot-reload watcher. No global template registry. Reload() exists on the engine for development use — it clears the partial cache so updated files are picked up without a process restart — but no filesystem events are monitored automatically.
The full implementation is a single file. Zero external dependencies. Every type in the public API is from the standard library or from html/template, which is part of the standard library.
The scope is deliberate. A layout engine that does exactly what html/template already does, minus the ceremony, will outlast any framework that tried to replace it.
A concurrency primitive for Go that ensures deterministic execution per key, the safety of a mutex with the granularity of a keyed routing system.
Concurrency in Go is usually about scaling wide, running as much as possible, as fast as possible, across every available core. But in systems like inventory, billing, or state machines, the challenge isn't how to run things in parallel, but how to stop them from doing so. When two requests attempt to mutate the same bank account or update the same warehouse SKU, parallelism is no longer an advantage; it is a liability. At that point, you don't need more speed, you need a predictable, serialized line.
A global mutex is easy to reason about and easy to get wrong in a different way: it makes everything wait for everything else. A per-resource mutex is more precise, but now you have a new problem — where do those mutexes live, who creates them, and when are they safe to delete?
The usual solutions
The first instinct is a sync.Map keyed by resource ID, with a sync.Mutex as the value. That works until you try to delete entries. A mutex stored in a map cannot be safely removed while another goroutine might be about to lock it. The window between a lookup and a lock is enough for a deletion to invalidate the pointer. You end up with a mutex map that only grows, or a mutex map that is subtly racy in ways that only appear under load.
The second instinct is to reach for a worker pool or a channel-per-resource. That brings its own lifecycle problem: you now manage goroutines on top of managing keys. And multi-resource operations — moving stock between two locations, for example — require acquiring two "lanes" without deadlocking, which channel-based designs rarely handle cleanly.
What Seriallane does instead
seriallane provides a "keyed mutex" primitive. It gives you the granularity of an actor model with the simplicity and performance of a standard lock.
Sequential execution per key. lanes.Do ensures that for any given key, only one function is executing. Other keys continue to run in parallel.
Explicit Key Construction. By using Namespace and Sub, the library nudges you toward a structured coordinate system. This prevents accidental collisions (like an "Order 123" and a "User 123" sharing the same lock string).
Deadlock-safe coordination. The hardest part of manual locking is handling multiple resources. seriallane handles this by enforcing deterministic lock ordering internally within DoMulti.
A Manager holds a map of keys to lanes. Each lane is a struct with a mutex and a timestamp. On a Do call, the manager finds or creates the lane for that key, acquires its mutex, runs the job, and releases. The caller sees none of this.
m := seriallane.New(10 * time.Minute)
err := m.Do(ctx, seriallane.Namespace("stock", locationID), func(ctx context.Context) error {
return adjustStock(ctx, locationID, delta)
})
Two concurrent calls with the same key queue behind each other. Two concurrent calls with different keys do not interact at all.
Multi-key operations
Moving stock between two locations requires holding both keys simultaneously. The obvious danger is lock inversion: goroutine A acquires key X then waits for Y; goroutine B acquires Y then waits for X. Classic deadlock.
DoMulti eliminates this by sorting keys into a canonical order before acquiring them. Any two callers that ask for the same set of keys will always acquire them in the same order, regardless of how the slice was constructed.
err := m.DoMulti(ctx, []seriallane.Key{
seriallane.Namespace("stock", fromID),
seriallane.Namespace("stock", toID),
}, func(ctx context.Context) error {
return moveStock(ctx, fromID, toID, qty)
})
The sort is done on a cloned slice so the caller's order is not touched.
Lifecycle without leaks
Lanes are created lazily. A system that processes thousands of distinct resources over time would accumulate thousands of lane entries, most of them idle. Seriallane solves this with an idle timeout and a background cleanup service.
svc := m.CleanupService(5 * time.Minute)
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
The cleanup identifies candidates under a read lock, then re-validates each one under a write lock before deleting. The two-phase check closes the window where a lane might become active between identification and removal — a lane that saw activity in that gap is left alone.
Mechanical Sympathy
Building a keyed mutex is easy. Building one that doesn't become a bottleneck is harder. seriallane is built with specific attention to the Go runtime:
- Non-blocking retrieval. Uses a double-checked locking pattern so that retrieving an existing lane is a non-blocking read operation.
- Two-phase maintenance. The
CleanupServiceidentifies garbage using a read-lock first, only switching to a write-lock for the final deletion. This prevents "latency spikes" during housekeeping. - Zero side-effects. The library doesn't log. It doesn't start hidden goroutines. It returns machine-readable wrapped errors and lets the application owner decide on the policy.
What was not built
seriallane is not a distributed lock manager. It does not speak to Redis. It has no retry logic, no persistence, and no "at-least-once" delivery guarantees.
It does not hide the control flow. There are no background queues or hidden state machines. When you call Do, your goroutine waits on a standard sync.Mutex. When the function returns, the lock is released. It is transparent, boring, and predictable.
The full implementation is under 200 lines of code. Zero external dependencies.
It is a sharp primitive designed to do one thing: turn chaos into a line, one key at a time.
Route prefix grouping and middleware inheritance for go's http.ServeMux.
Go 1.22 finally gave http.ServeMux what it had been missing for years: method routing and path parameters. For a lot of projects, that removes the last real reason to reach for a router library.
Except for two things. Prefix grouping. Middleware inheritance.
Without those, every ServeMux application ends up with the same boilerplate: route strings repeated with their prefix, middleware applied by hand to each handler, and no clean way to say "everything under /api runs through auth." The natural response is to pull in a framework, despite needing only those two features.
What muxx adds
muxx is a thin layer over ServeMux that provides exactly those two things.
r := muxx.New()
r.HandleFunc("GET /", homeHandler)
api := r.Group("/api")
api.Use(authMiddleware)
users := api.Group("/users")
users.HandleFunc("GET /", listUsers)
users.HandleFunc("POST /", createUser)
users.HandleFunc("GET /{id}", getUser)
http.ListenAndServe(":8080", r)
GET /users/{id} registers as GET /api/users/{id} in the underlying ServeMux. authMiddleware runs on every route under /api, including the nested /users group. Adding more middleware to the users group does not affect sibling groups or any already-registered route.
The key design decision
Groups are a registration-time concept only. When HandleFunc is called, the prefix is resolved and the middleware chain is wrapped and the resulting handler is registered directly in the shared ServeMux. By the time a request arrives, the router is entirely flat.
This means r.PathValue() works natively, there is no request rewriting, and there are no layers to trace through when something behaves unexpectedly. The ServeMux you know is the ServeMux running.
Host-based routing
Group follows the same pattern format as ServeMux itself. A prefix that does not start with / is treated as a host — no separate API, no new concept to learn.
api := r.Group("api.example.com")
api.Use(authMiddleware)
api.HandleFunc("GET /users", listUsers)
Host and path prefix can be expressed in one call:
admin := r.Group("admin.example.com/settings")
admin.HandleFunc("GET /profile", profileHandler)
// registers as: GET admin.example.com/settings/profile
Middleware inheritance, nesting, and PathValue all work identically whether the group is path-scoped or host-scoped.
Middleware without a router
Chain is available for cases where you want to compose middleware onto a single handler without involving the router at all:
h := muxx.Chain(myHandler, authMiddleware, loggingMiddleware)
Middleware runs in declaration order. The first argument is the outermost wrapper, closest to the request.
What was not built
No route introspection. No named routes. No parameter validation. No request context helpers. No generated code.
The public API is eight functions. The entire package is one file. Zero external dependencies. Everything in the API is either a standard library type or a named alias for func(http.Handler) http.Handler.
If you already know net/http, there is nothing new to learn here.
A minimal, UDP broadcast discovery package for Go.
Service discovery on a local network sounds simple. A device comes online, other devices need to find it, and no one wants to manage a central registry just for that. UDP broadcast is the natural answer: send a probe, wait for a reply, done.
The standard library has everything required. net.ListenUDP, WriteToUDP, ReadFromUDP. The pieces are there. What it does not give you is the wiring: context cancellation that actually unblocks a pending read, a clean separation between the broadcaster and the responder, and the structural discipline to avoid allocating on every incoming packet in a hot loop.
That gap is where beacon lives.
The shape of the problem
A UDP broadcast discovery pattern has two roles. One side — the seeker — broadcasts a probe to the network and waits for any node that recognises it to respond. The other side — the server — listens permanently, validates incoming bytes, and replies with a payload when a probe matches.
Both sides need context support. The seeker needs a deadline; network discovery that waits forever is broken by design. The server needs cancellation; it holds a socket open for the lifetime of a process and must release it cleanly.
Both sides share a subtlety with allocation. The typical Go pattern for reading from a UDP socket allocates a new buffer on each call. For a discovery server handling frequent probes on embedded hardware or a high-throughput internal service, that pressure adds up. The fix is caller-provided buffers: you allocate once and hand the slice in.
What beacon does
Beacon exposes exactly two functions: Seek and Serve.
Seek broadcasts a probe to a target address and reads the first reply into a caller-provided buffer. Context drives the timeout. When the deadline fires, the pending ReadFromUDP is unblocked by closing the connection from a goroutine bound to ctx.Done(), so the caller gets context.DeadlineExceeded rather than a raw network error.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
probe := []byte("PING")
buf := make([]byte, 1024)
n, addr, err := beacon.Seek(ctx, "255.255.255.255:9999", probe, buf)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Fatal("no service found within deadline")
}
log.Fatalf("discovery failed: %v", err)
}
log.Printf("found service at %s: %s", addr, buf[:n])
Serve binds to a listen address and blocks in a read loop. For each incoming packet, it calls a caller-provided validation function with the received bytes. If the function returns true, it sends the payload back to the sender. If the context is cancelled, the connection is closed and Serve returns the context error. No goroutines outlive the call.
buf := make([]byte, 1024)
expected := []byte("PING")
payload := []byte("192.168.1.50:8080")
isValid := func(probe []byte) bool {
return bytes.Equal(probe, expected)
}
err := beacon.Serve(ctx, ":9999", buf, isValid, payload)
if err != nil && !errors.Is(err, context.Canceled) {
log.Fatalf("server error: %v", err)
}
The validation function receives only the bytes that were actually read — buf[:n], not the full buffer — so it sees exactly the probe content and nothing more.
Errors as values
Every failure mode in beacon is wrapped with a sentinel error. ErrInvalidAddr, ErrListen, ErrWriteProbe, ErrReadResponse, ErrReadProbe, ErrWriteReply. Each wraps the underlying network error with fmt.Errorf("%w: %w", ...), so callers can use errors.Is against the sentinel to identify the failure class and still inspect the original error when they need detail.
n, addr, err := beacon.Seek(ctx, target, probe, buf)
if errors.Is(err, beacon.ErrWriteProbe) {
// the socket was valid but the broadcast failed — likely a permissions issue
}
Context errors are never wrapped. When ctx.Err() is the cause, that error is returned directly so errors.Is(err, context.DeadlineExceeded) works without any unwrapping ceremony.
What was not built
Beacon does not retry. It does not aggregate multiple replies from different nodes. It does not define a probe format, a serialization scheme, or a handshake. It does not maintain a registry of discovered services or emit events when nodes appear and disappear.
Those are all valid things to build. They belong in the caller, where the protocol semantics are known.
The implementation is a single file. Zero external dependencies. The only concurrency is one goroutine per active call, strictly scoped to context cancellation, with no possibility of outliving the function that spawned it.
The constraint is the point. A discovery primitive that imposes no protocol and owns no state composes into anything. One that makes choices for you composes into nothing.
A concurrency primitive for managing long-lived background services in Go.
Go's go func() is the finest concurrency primitive in modern systems programming. It is practically free to invoke, multiplexes cleanly onto OS threads, and requires no runtime configuration. But it is entirely unmanaged. You write go worker() and from that point on, you are on your own.
If that worker panics, your process crashes. If it silently exits, your queue stops draining. If you need to shut down cleanly, you end up writing the same fragile web of sync.WaitGroup, chan struct{}, and deferred cancellations that you wrote in the last package — slightly different every time, never quite right.
rungroup addresses this with a single primitive: a group that supervises its members, and a single blocking interface to run them all.
One interface
Every managed process implements one method:
type Service interface {
Run(ctx context.Context) error
}
The contract is unambiguous. Block until the work is done or the context is cancelled, then return. There is no Start(), no Stop(), no Reload(). If you can express your process as a blocking function that respects context, it works here.
For functions that don't warrant a full type, ServiceFunc is the adapter:
g.Add(
rungroup.ServiceFunc(func(ctx context.Context) error {
return server.Serve(ctx)
}),
rungroup.WithName("http-server")
)
Restart policies
Each service carries its own restart policy. The default is RestartAlways — if a service exits for any reason, it is restarted. RestartOnFailure restricts restarts to non-nil returns. RestartNever disables restarts entirely; the service runs once and its exit is recorded.
Policies are evaluated after every exit. A service that returns nil under RestartAlways is restarted. A service that returns an error under RestartOnFailure is restarted. In both cases, the exit is not treated as a terminal event — it is treated as a transition.
This distinction matters. A queue worker that finishes its batch and returns nil should be restarted if you want continuous processing. An HTTP server that exits cleanly should probably not be restarted without investigation. The policy makes that decision explicit at registration time, not buried in the service implementation.
Backoff
A failing service that restarts immediately and fails again immediately is a spin-loop. rungroup applies a 50 ms delay between restarts by default. When you need a real strategy, backoff is a pure function:
g.Add(worker,
rungroup.WithRestartPolicy(rungroup.RestartOnFailure),
rungroup.WithBackoff(func(attempt int) time.Duration {
if attempt > 10 {
return -1 // give up
}
return time.Duration(attempt) * time.Second
}),
)
The attempt counter is passed in; you return the delay. Returning a negative duration is a hard stop: the service is permanently abandoned and ErrRestartLimitExceeded is recorded. There is no hidden state, no exponential calculation you can't inspect. The function is the policy.
One edge case matters here: a service that runs stably for hours, then crashes once, should not inherit a large backoff calculated from the total lifetime restart count. WithStabilityWindow handles this. If the service ran continuously for at least the specified duration before crashing, the attempt counter resets to zero before the next backoff is calculated.
rungroup.WithStabilityWindow(5 * time.Minute),
Panic recovery
A panicking goroutine will bring down your process. rungroup wraps every Run call in a deferred recovery. If a service panics, the panic value and full stack trace are captured and converted into an error. That error is then evaluated against the service's restart policy — it is not treated differently from any other failure. If the policy says restart, it restarts.
This means a service that occasionally panics due to a nil pointer in a corner case does not take down the process. It restarts, emits an event, and continues. The stack trace in the error gives you the information you need to fix it.
Sentinel errors
Two sentinel errors give services explicit control over group-level behaviour.
ErrDoNotRestart permanently halts the service, regardless of its configured policy. Use this when a service encounters a condition it has diagnosed as unrecoverable — a corrupt state, a poison message, a configuration error. It stops only that service. Everything else continues.
func (w *Worker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
case msg := <-w.queue:
if msg.Version > maxSupportedVersion {
return fmt.Errorf("unsupported protocol: %w", rungroup.ErrDoNotRestart)
}
w.process(msg)
}
}
}
ErrShutdownAll goes further. It cancels the group's internal context, triggering a graceful shutdown of every running service. Use this when a service detects an application-wide failure state where continued operation is not meaningful — a lost exclusive database lock, a mandatory dependency that has gone permanently offline.
func (s *Store) Run(ctx context.Context) error {
if err := s.ping(ctx); err != nil {
return fmt.Errorf("lost database connection: %w", rungroup.ErrShutdownAll)
}
// ...
}
Both sentinels are matched via errors.Is, so they compose naturally with fmt.Errorf wrapping.
Shutdown
g.Run(ctx) blocks until the provided context is cancelled, then waits for every service goroutine to exit before returning. The returned error is a joined aggregate of all terminal errors recorded during the group's lifetime.
Clean shutdowns require services that respect context cancellation. rungroup cannot force a goroutine to stop — Go does not allow it. What it can do is give you a deadline. WithShutdownTimeout sets a global ceiling on how long Run will wait after the context is cancelled. WithServiceShutdownTimeout sets an individual ceiling per service.
g := rungroup.New(rungroup.WithShutdownTimeout(15 * time.Second))
g.Add(legacyService,
rungroup.WithName("legacy-db-sync"),
rungroup.WithServiceShutdownTimeout(3 * time.Second),
)
The two timeouts race. Whichever fires first wins for that service. When a service exceeds its timeout, an EventShutdownTimeout event is emitted, the goroutine is abandoned, and ErrShutdownTimeout is recorded in the aggregate error. The group does not hang.
Observability
rungroup does not accept a logger. It emits structured events.
g := rungroup.New(
rungroup.WithEventHandler(func(e rungroup.Event) {
switch e.Type {
case rungroup.EventServiceRestarting:
slog.Warn("service restarting",
"service", e.ServiceName,
"attempt", e.Attempt,
"delay", e.Delay,
"err", e.Err,
)
case rungroup.EventServiceHalted:
slog.Error("service halted", "service", e.ServiceName, "err", e.Err)
case rungroup.EventShutdownTimeout:
slog.Error("service exceeded shutdown timeout", "service", e.ServiceName)
}
}),
)
You own the handler. You decide whether that means structured logging, a metrics counter, an alert, or nothing at all. A per-service handler can also be registered with WithServiceEventHandler for cases where a specific service needs its own observation logic. It fires before the group-level handler.
Supervision trees
*Group implements Service. This is not a coincidence. It means you can add one group to another and build a two-level supervision hierarchy with no additional API surface.
internalGroup := rungroup.New()
internalGroup.Add(cacheWarmer)
internalGroup.Add(metricsCollector)
root := rungroup.New()
root.Add(httpServer, rungroup.WithName("http"))
root.Add(internalGroup, rungroup.WithName("internal"))
root.Run(ctx)
By default, ErrShutdownAll propagates upward. If a service inside internalGroup triggers a shutdown, it will also shut down httpServer. When that is not the behaviour you want — when a subsystem should fail in isolation — add WithIsolateShutdown to the inner group's registration:
root.Add(internalGroup,
rungroup.WithName("internal"),
rungroup.WithIsolateShutdown(),
)
The shutdown signal is absorbed at that boundary. The root group continues running. The inner group's failure is recorded as a policy halt, not a group-wide event.
Interval services
A common pattern is a background task that runs on a fixed schedule — a cache refresh, a metrics flush, a lease renewal. The naive implementation is a for loop with a time.Sleep, which does not respect context cancellation cleanly. The slightly better implementation uses time.NewTicker with a select, which works but is boilerplate you write the same way every time.
IntervalService encapsulates that pattern:
g.Add(
rungroup.NewIntervalService(30*time.Second, func(ctx context.Context) error {
return cache.Refresh(ctx)
}),
rungroup.WithName("cache-refresh"),
rungroup.WithRestartPolicy(rungroup.RestartOnFailure),
)
The handler is called immediately on start, then on every tick. If the handler returns a non-nil error, Run returns it and the group's restart policy takes over. Context cancellation is checked between ticks, so shutdown is always clean.
What was not built
There are no dependency graphs between services. If Service B needs Service A to be ready before it starts, pass a chan struct{} into B and block on it inside Run. That is a Go problem, not a supervision problem.
There are no health checks or liveness probes. If a service is unhealthy, it should detect that condition, return an error, and let the group restart it per policy.
There is no dynamic service registration after Run has started. All services must be added before the group is running. Add returns ErrAlreadyRunning if you attempt otherwise.
The implementation is a single file. One goroutine per service. No pools, no background housekeeping goroutines, no hidden locks beyond the mutex protecting the terminal error slice. You can read the source in full in under an hour, and that will still be true the next time something breaks at midnight.
A primitive for issuing and verifying signed, time-limited URLs using HMAC-SHA256.
A common pattern in web applications is generating a URL that should only work for a limited time. A file that a user has paid to download. A password-reset link. A one-time invite. The URL needs to be accessible without authentication — but not by anyone, and not forever.
The instinct is to reach for sessions or a database. Store a token, associate it with a resource and an expiry, look it up on each request, delete it when used. That works, but it moves the problem into storage. Now you have a table to manage, a cleanup job to run, and a read on every request that serves a signed URL.
The other instinct is JWT. But JWT is a format specification for a container of claims, and most of its surface area — key types, algorithm negotiation, claim namespaces — is overhead for a problem this narrow. You don't need a registered claim set. You need a path and a timestamp that can't be tampered with.
What actually needs to be true
For a time-limited URL to be secure, four things must hold:
- The expiry cannot be altered without invalidating the URL.
- A valid signature on one URL cannot be transplanted to a different host or path.
- Additional query parameters cannot be altered without invalidating the URL.
- Verification must not leak timing information that could help an attacker forge a signature.
Nothing else is required. No server state, no token registry, no revocation list.
What urlsign does
urlsign appends two query parameters to a URL: exp, a Unix timestamp representing when the URL expires, and sig, the hex-encoded HMAC-SHA256 of host:path:exp:canonical_query.
https://example.com/files/report.pdf?exp=1747123456&sig=9f3a...
The HMAC key is a secret you hold. The MAC input binds the signature to four things: the host (including port, if present), the path, the expiry, and all other query parameters in canonical form. Altering any one of them — extending the expiry, changing the path, switching the host, modifying a query parameter — produces a different MAC and fails verification. A signed link for cdn.example.com cannot be replayed against evil.example.com. A link to /files/report.pdf?format=pdf cannot have its format parameter swapped to csv.
Canonical query form means the remaining parameters are sorted by key and URL-encoded. Order at signing time is irrelevant; the same bytes are produced regardless.
Verification reads exp and sig from the URL, recomputes the expected MAC, and compares in constant time using hmac.Equal. Early-exit string comparison would allow an attacker to learn, one byte at a time, how close their forgery attempt is. Constant-time comparison closes that window.
s, err := urlsign.NewSigner([]byte(secretKey), time.Hour)
if err != nil { ... }
// Issuing — typically at link generation time
signed, err := s.Sign("https://cdn.example.com/files/report.pdf")
// Verifying — in a handler or middleware
if err := s.VerifyRequest(r); err != nil {
// errors.Is(err, urlsign.ErrExpired)
// errors.Is(err, urlsign.ErrInvalidSig)
// errors.Is(err, urlsign.ErrMissingParams)
}
The Signer holds the key and the TTL. Sign takes a raw URL and returns a signed one. Verify and VerifyRequest are the same operation — one takes a string, one takes an *http.Request. The key is copied on construction; the caller's slice is not held.
What this does not solve
urlsign has no concept of revocation. A signed URL is valid until it expires. If you need to invalidate links before their expiry — cancelled subscriptions, compromised tokens — you need storage. That is a different problem with different trade-offs, and this package does not collapse them together.
There is no middleware, no key rotation, no multi-key verification. The Signer holds one key. Rotation is a deployment concern; if you need it, issue new URLs with the new key before retiring the old one.
A typed single-channel pub/sub primitive for Go.
Most Go applications eventually need to decouple things. A user is created; an email should go out, a welcome credit should be applied, an audit record should be written. The obvious first implementation puts all of that in the handler that creates the user. It works until it doesn't — until the email service is slow, or the audit write fails and rolls back work that shouldn't be rolled back, or a new requirement arrives and the handler becomes a directory of unrelated concerns held together by proximity.
The standard answer is an event system: emit an event, let interested parties react independently. The question is what that system should look like in Go.
The usual solutions
The first instinct is a channel. Channels are Go's native primitive for decoupling producers from consumers, and for a single consumer they are perfect. The problem is fanout. A channel has one reader. If three services care about UserCreated, you need three channels, or a broker goroutine that reads one and writes to three, or a shared slice of channels that every publisher must know about. None of that is clean, and all of it is manual.
The second instinct is a third-party event bus. Most of them work the same way: subscribe with a string topic name, receive interface{} or some Event interface, assert the type on the other side. It hides the wiring, but it trades the channel's type safety for runtime assertions everywhere:
bus.Subscribe("user.created", func(e Event) {
user, ok := e.(UserCreated) // every subscriber does this
if !ok {
return
}
// ...
})
The compiler cannot help you here. A typo in the topic string, a wrong type assertion, a handler registered for the wrong event — all of these become runtime surprises, often in production, often under load. The hidden global registry means you cannot trace what is subscribed to what without running the program.
What generics changed
Before Go 1.18, a typed event bus was not really possible without code generation or reflection. Generics changed that. A Topic[T] can hold a typed list of handlers, enforce that every subscriber accepts exactly T, and dispatch without a single type assertion anywhere in the call path.
The result is a contract enforced by the compiler:
users := topic.New[UserCreated]()
users.Subscribe(func(ctx context.Context, evt UserCreated) error {
return sendWelcomeEmail(ctx, evt)
})
users.Subscribe(func(ctx context.Context, evt UserCreated) error {
return applyWelcomeCredit(ctx, evt)
})
Both handlers run on every publish. The compiler guarantees they both accept UserCreated. No string keys, no type assertions, no global registry. The event topology lives in your code, not in a runtime map.
Fanout by default
A conventional command bus halts on the first error: if step two fails, steps three and four don't run. That is correct behaviour for a pipeline, but wrong for pub/sub. If the billing handler fails, the audit handler should still run. The two concerns are independent.
topic treats every publish as a fanout. All handlers always run. Errors are collected and returned as a joined error so the publisher has full visibility — without any one handler's failure masking another's:
if err := users.Publish(ctx, UserCreated{ID: "u_123"}); err != nil {
// err contains every handler error, joined
// both handlers ran regardless
}
The publisher decides what to do with the aggregate error. The topic's job is to dispatch faithfully and report completely.
The concurrency problem
A naive implementation holds a read lock for the duration of dispatch. That works until a handler tries to subscribe during its own execution — deadlock. It also means a slow handler blocks every concurrent publisher waiting on the lock.
topic uses copy-on-write instead. The handler list sits behind an atomic.Pointer. Writes copy the slice and swap the pointer under a mutex. Reads — every Publish call — do a single atomic load and iterate. No lock is held during dispatch, so concurrent publishers never contend and mid-dispatch subscriptions are safe.
BenchmarkPublish_1Handler-8 326000000 3.7 ns/op 0 allocs/op
BenchmarkPublish_10Handlers-8 60000000 20.0 ns/op 0 allocs/op
BenchmarkPublish_Parallel-8 1000000000 0.6 ns/op 0 allocs/op
Parallel throughput exceeds single-threaded throughput because concurrent publishers never wait for each other.
Middleware without magic
Cross-cutting concerns — retry on failure, recovery from panics, logging — are handled through a composable option type:
type Option[T any] func(Handler[T]) Handler[T]
This is the standard HTTP middleware pattern applied to event handlers. Each option wraps the next, forming a chain. The options are methods on *Topic[T], which means the type parameter is always inferred at the call site — no angle brackets in application code:
cancel := orders.Subscribe(handler,
orders.WithRecovery(),
orders.WithRetry(3, 500*time.Millisecond),
)
WithRecovery wraps first, so a panicking handler becomes an error before the retry loop sees it. The composition order is explicit and readable. No configuration structs, no builder patterns, no reflection.
Building an event bus
Topic[T] is a single typed channel. An event bus is just a struct that collects them:
type AppEvents struct {
UserCreated *topic.Topic[UserCreated]
OrderPaid *topic.Topic[OrderPaid]
StockUpdated *topic.Topic[StockUpdated]
}
Wire it at startup, pass individual topics to the services that need them. A service that publishes orders gets *topic.Topic[OrderPaid]. It cannot accidentally publish to the user topic. The compiler enforces the boundary.
There is no registration mechanism, no discovery, no string routing. The event bus is a value, readable from top to bottom, with no behaviour of its own.
What was not built
topic does not manage goroutines. Async dispatch, buffered channels, worker pools — these have lifecycles that belong to the application, not the library. A handler that needs buffering can receive into a channel it owns and manages itself.
topic does not provide a global registry. There is no topic.Subscribe("user.created", ...). The absence is intentional: a global registry is hidden state, and hidden state fails silently at scale.
topic does not implement an Event interface. There is no EventName() string, no common base type, no serialisation concern. Those belong at integration boundaries — the HTTP layer, the Kafka consumer, the WebSocket handler — not inside in-process dispatch.
The full implementation is under 200 lines. Zero external dependencies. The entire call path from Publish to handler invocation is traceable with Go to Definition alone.
A context-aware retry loop driven by composable policies.
Most network calls fail sometimes. A database connection drops. A downstream service returns a 503. A DNS lookup times out once and succeeds the next. The right response to all of these is the same: wait a moment, try again.
The retry logic itself is not complicated. The problem is that you write it differently every time — a loop here, a counter there, sleep logic that does not honour context cancellation, backoff that is either hardcoded or buried in configuration. Each implementation is slightly different from the last, and none of them are the thing you actually wanted to write.
retry gives you the loop once, done correctly, and hands the strategy back to you.
The problem with most retry implementations
The usual approach is to reach for a library with a configuration struct. You set MaxRetries, InitialInterval, Multiplier, MaxInterval, RandomizationFactor. You spend ten minutes reading the docs to understand what RandomizationFactor actually does to the sleep duration. You accept a transitive dependency you did not ask for.
Or you write it yourself. A for loop, a counter, a time.Sleep. It works, until someone points out that it does not respect context cancellation. You add a select. Now it is fifteen lines of boilerplate that you will copy into the next service, and the one after that.
The actual decision, how long to wait and when to give up, is two lines of business logic surrounded by ten lines of machinery. The machinery should not be your problem.
Policy as a function
retry separates the machinery from the decision. The machinery is Do. The decision is a Policy:
type Policy func(attempt int) time.Duration
Return a positive duration to sleep before the next attempt. Return zero to retry immediately. Return a negative duration to stop. That is the entire contract. There is no interface to implement, no struct to configure. A policy that gives up after five attempts with linear backoff is four lines:
func(attempt int) time.Duration {
if attempt >= 5 { return -1 }
return time.Duration(attempt) * 200 * time.Millisecond
}
You can read it, test it, and change it without touching anything else.
Built-in policies and composition
The common strategies ship as functions — Exponential, Constant, Linear, Always, Never. None of them encode a call limit, because the limit is not part of the strategy. It is a separate concern, and MaxAttempts handles it:
retry.MaxAttempts(5, retry.Exponential(100*time.Millisecond, 30*time.Second))
Read left-to-right: five total attempts, exponential backoff, 100 ms base, 30 s cap. The exponential policy does not know about the limit. MaxAttempts does not know about exponential backoff. They compose cleanly because neither owns more than its share.
When the strategy and the limit are fused into one configuration struct, changing one often touches the other. When they are separate functions, you can swap the strategy without changing the limit, or adjust the limit without touching the backoff curve.
When not to retry
Retrying everything unconditionally is usually wrong. A 404 does not get better with time. A validation error will fail on the second attempt for exactly the same reason it failed on the first. Retrying these wastes time and obscures the real problem.
ErrDoNotRetry handles this. Wrapping it in any error returned from fn stops the loop immediately, regardless of remaining attempts, and preserves the full error chain so the caller can tell why:
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("user %d: %w", id, retry.ErrDoNotRetry)
}
The distinction between "failed, try again" and "failed, do not try again" belongs in the function that knows what the error means, not in the retry loop.
Knowing when you have given up
When the policy exhausts all attempts, Do returns the last error wrapped in ErrRetryLimitExceeded. This gives callers a reliable way to distinguish a context cancellation, a permanent failure, and an exhausted retry budget, without parsing error strings:
switch {
case errors.Is(err, context.Canceled):
// context was cancelled
case errors.Is(err, retry.ErrDoNotRetry):
// fn signalled a permanent failure
case errors.Is(err, retry.ErrRetryLimitExceeded):
// all attempts used up; the underlying cause is still in the chain
}
Each outcome calls for a different response. Conflating them into a single error check means retrying things that should not be retried, or treating an exhausted retry budget as a cancellation.
Context cancellation
Context cancellation is checked in two places: before every call to fn, and during the sleep between attempts. A cancelled context is never silently swallowed. If the deadline fires halfway through a 30-second backoff sleep, Do returns ctx.Err() immediately.
This means you can set a timeout on the entire retry operation without coordinating it with the per-attempt backoff:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := retry.Do(ctx, retry.Exponential(100*time.Millisecond, 10*time.Second), fn)
The loop will run for at most five seconds, however many attempts that turns out to be. The timeout and the backoff strategy are independent. Neither needs to know about the other.
What was not built
Per-attempt timeouts
Some libraries let you set a deadline on each individual call to fn. This was left out. A per-attempt timeout belongs inside fn: pass a derived context, use context.WithTimeout. The retry loop does not need to know about it, and adding that knowledge would couple the loop to concerns that are not its own.
An OnRetry callback
An earlier version had this. It was cut. If you need to log each failed attempt, wrap fn:
retry.Do(ctx, policy, func() error {
err := fn()
if err != nil {
slog.Warn("attempt failed", "err", err)
}
return err
})
That is one line more than an OnRetry option would be, and it requires no additional API surface.
Jitter as a separate combinator
Splitting Exponential into a deterministic strategy and a separate WithJitter wrapper seemed clean on paper. In practice, exponential backoff without jitter is almost always wrong in distributed systems; it causes retry storms. Fusing jitter into Exponential removes a footgun without adding complexity. Constant and Linear are left deterministic because the use cases are different.
A default policy
Do has no fallback if you pass something unexpected. You provide a policy. This is intentional: a silent default would mean callers who forget to pass a policy get undefined behaviour rather than a compile error. The type system handles it.
A primitive toolkit for building binary protocols in Go.
At some point most networked applications need a binary protocol. Not because JSON or gRPC won't work, but because the situation calls for it: a device on a serial bus, a high-frequency internal service where framing overhead matters, a game server where every byte counts. The question is how to build it without spending half the project on plumbing.
The standard library gives you encoding/binary for byte-order encoding, hash/crc32 for checksums, and io.Reader for reading data off the wire. None of them know about each other. The framing (where a packet starts and ends) is your responsibility. So is buffering partial reads, distinguishing a clean EOF from a half-written frame, and avoiding allocations in the hot read loop. Not hard problems. But they require attention every time, and the cost is paid silently when someone gets them wrong.
wireframe is a toolkit for that work. Define a format, get an encoder, a decoder, and a streaming reader. You declare the structure; the codec does the mechanical work. No generated code, no schema compiler, no framework making decisions about your byte layout.
The alternatives fall short
Serialisation frameworks like Protobuf handle framing, integrity, and field encoding in one step, but they own the wire format. The .proto schema is the authority; the generated code decides the byte layout. That works well when you are defining a protocol from scratch and are willing to let the framework drive. It stops working the moment you need precise control: implementing an existing industrial protocol, targeting a device with strict layout requirements, or just wanting to know exactly what bytes go on the wire without reading generated code.
The ceremony adds up even when the framework fits. A schema file, a code generation step, generated files checked into source control that drift each time the schema changes, a build dependency on the schema compiler. For a tight internal format or a compact embedded protocol, that is a lot of moving parts for a problem that is mostly byte layout.
Rolling it by hand is viable but repetitive. encoding/binary is correct. But every project ends up writing the same wrapper: a readFull loop, a length-prefix field, a CRC verification block, a partial-read state machine. That wrapper never gets extracted into anything reusable. It gets copied, slightly modified, and copied again.
Declaring a format
A Format describes one frame type. You supply the prefix bytes, the ordered header fields, the checksum algorithm, and a maximum payload size. The codec handles the rest.
format := &frame.Format{
Prefix: []byte{0xAA, 0x55},
Header: []frame.Field{
frame.Uint8("version"),
frame.Uint8("type"),
frame.Uint8("flags"),
frame.PayloadLengthLE("length"),
},
Checksum: checksum.CRC32IEEE(),
MaxPayload: 64 * 1024,
}
Field types map directly to wire widths: Uint8, Uint16BE, Uint16LE, Uint32BE, Uint32LE. PayloadLength variants encode the payload size into the specified field so the decoder knows where the payload ends. Byte order is declared explicitly at every field. There is no detection heuristic, no default.
Format.Validate checks consistency and pre-computes derived values. Encode and Decode call it lazily on first use; call it explicitly at startup to catch configuration errors before serving traffic.
Encoding and decoding
Encoding populates a Values and calls Encode. Values is a value type: declare it on the stack, call Reset between frames, and nothing goes to the heap.
var vals frame.Values
frame.Set(&vals, "version", uint8(1))
frame.Set(&vals, "type", uint8(0x42))
frame.Set(&vals, "flags", uint8(0))
out, err := format.Encode(nil, payload, &vals)
if err != nil {
return err
}
conn.Write(out)
Encode(dst []byte, ...) follows the append pattern. Pass nil to allocate a fresh slice; pass a pre-grown one to reuse it across frames.
Decoding is the mirror:
payload, n, err := format.Decode(src, &vals)
if errors.Is(err, frame.ErrChecksum) {
return fmt.Errorf("integrity check failed: %w", err)
}
if errors.Is(err, frame.ErrIncomplete) {
// src does not yet contain a full frame — buffer more data
}
ErrIncomplete means the frame boundary has not been reached yet. Buffer more data and try again. stream.Reader handles this automatically.
Reading from a stream
Data from a socket or serial port rarely arrives aligned to frame boundaries. stream.NewReader wraps any io.Reader, buffers data, handles partial reads, and returns complete frames one at a time.
sr := stream.NewReader(conn, format)
for {
payload, vals, err := sr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
switch vals.Uint8("type") {
case 0x42:
handle(payload, vals)
}
}
Next returns io.EOF when the underlying reader closes cleanly at a frame boundary, and io.ErrUnexpectedEOF when it closes mid-frame, so callers can distinguish a clean shutdown from a truncated packet.
vals is valid until the next call to Next. Copy it if the header needs to outlive one iteration. For the lifetime of the connection, Next allocates nothing per frame.
Struct-backed formats
The Values API is allocation-free, but field names are strings validated only at runtime. A typo in "vesrion" compiles without complaint.
TypedFormat[T] binds the codec directly to a struct. Accessor functions connect the wire field names to the struct fields; the compiler catches type mismatches at every call site.
type Header struct {
Version uint8
Type uint8
Flags uint8
Length uint16
}
var msgFormat = frame.NewTypedFormat[Header](
frame.WithPrefix[Header]([]byte{0xAA, 0x55}),
frame.Uint8Field("version", func(h *Header) *uint8 { return &h.Version }),
frame.Uint8Field("type", func(h *Header) *uint8 { return &h.Type }),
frame.Uint8Field("flags", func(h *Header) *uint8 { return &h.Flags }),
frame.PayloadLengthField("length", func(h *Header) *uint16 { return &h.Length }),
frame.WithChecksum[Header](checksum.CRC32IEEE()),
frame.WithMaxPayload[Header](64*1024),
)
// Encoding
hdr := Header{Version: 1, Type: 0x42}
out, err := msgFormat.Encode(nil, payload, &hdr)
// Decoding
var hdr Header
payload, n, err := msgFormat.Decode(src, &hdr)
TypedFormat[T] wraps the untyped Format. The accessor functions translate between *T and *Values with no runtime cost. The untyped API is still there when you need it.
Describing the payload
frame handles the outer envelope: prefix, header, checksum, boundaries. schema handles the structured data inside the payload. The two packages are independent; use one, both, or neither.
s := schema.New(
schema.Uint8("command"),
schema.Uint16BE("seq"),
schema.VarBytes("data", "seq"),
)
var vals schema.Values
schema.Set(&vals, "command", uint8(0x01))
schema.Set(&vals, "seq", uint16(42))
schema.Set(&vals, "data", []byte("hello"))
encoded, err := s.Encode(nil, &vals)
VarBytes("data", "seq") encodes a variable-length byte slice using the seq field value as the length prefix. The dependency is declared in the schema; the codec resolves it at encode and decode time. schema also has a TypedSchema[T] variant for struct-backed encoding.
The Values design
The obvious alternative is map[string]any. Every field value goes through an interface, which means a heap allocation per field per frame. At 100K frames per second on a high-throughput bus, that pressure adds up.
Values uses a fixed-size inline array: 16 slots of (string, uint64|[]byte). Integer values go in as uint64 with no interface boxing. String lookup is a linear scan over keys[0:n]. Protocol headers are almost never more than eight fields; at that scale a linear scan is faster than a map.
Callers declare Values on the stack. Reset zeroes the count. The struct does not escape to the heap in the common case. stream.Reader holds a Values as a struct field, so the allocation budget for a running connection is exactly one: the initial NewReader call.
Values is not a general key-value store. It is sized for protocol headers and nothing else.
What was not built
Struct tags and reflect would let callers write Encode(format, &myStruct) without accessor functions. That path hides the encode/decode mapping from the toolchain, breaks go tool analysis, and pulls in reflect for a problem that accessor functions already solve with no runtime cost. It is more code to write with accessor functions. The types are also checked by the compiler and the mapping is visible in the code, which is worth it.
Encryption and compression belong above wireframe, applied to the payload before it reaches the codec. Mixing them in would force every caller to carry the cost regardless of whether they need it. Same logic applies to a Writer wrapping io.Writer: it would own the write call and remove the caller's ability to batch frames or control flush timing, so it was not built.
Version fields and type dispatching are values in the header. What to do with them is your logic, not the codec's.
The full implementation is 13 files across 7 packages. Zero external dependencies. Everything wireframe needs is in io, bufio, bytes, encoding/binary, hash/crc32, hash/crc64, and hash/adler32. Read the source, vendor it, take it. The code is small enough to hold in one sitting.
Minimal Server-Sent Events primitives for Go
You need to push updates to a client. The instinct is almost always to reach for WebSockets. You import a heavy WebSocket framework, rewrite your routing, fight with load balancer timeouts, and commit to maintaining a complex bidirectional protocol—just to push a few data payloads downstream. It is a massive hammer for a small nail.
The right tool has been sitting in the browser for a decade: Server-Sent Events (SSE). It is just text sent over a long-lived HTTP connection. It is unidirectional, leverages standard HTTP semantics, and requires zero client-side dependencies.
Yet, implementing SSE in Go usually leads to one of two outcomes: a fragile string-concatenation loop you wrote yourself, or a heavy framework that takes over your entire network stack.
The protocol itself is not complicated. The problem is managing the connection state, handling concurrent writes, keeping the connection alive through intermediate proxies, and parsing incoming streams without dropping bytes or churning the heap.
sse gives you the protocol primitives, mechanically optimized, and hands control of the network back to you.
The problem with most SSE implementations
The usual approach is to reach for a library. You import a package that abstracts the HTTP handler completely. It spins up hidden background goroutines to manage connections and broadcast channels. But hidden state fails silently. When a client disconnects, those hidden workers often leak or block infinitely.
Or, you write it yourself. You use fmt.Sprintf("data: %s\n", payload) and write it directly to the response. It works, until you have a thousand connected clients and realize you are allocating thousands of intermediate strings on the heap every second. Allocations are not free.
The actual requirement, formatting an event and flushing an HTTP buffer, gets buried under network managers and memory churn. The machinery should respect the runtime, but it should not become your application.
Separating data from state
sse separates pure data from network machinery. An Event is just a struct:
type Event struct {
Name string
ID string
Retry time.Duration
Data string
Extensions map[string]string
}
It holds data. It has no methods that read from or write to the network.
The state is managed by the Emitter (for servers) and the Reader (for clients). They connect through the interfaces the language already established, http.ResponseWriter and io.Reader, so they compose naturally into your existing codebase.
Zero-allocation formatting
When sending events, sse treats the standard library with the respect it deserves. The Emitter does not use fmt.Sprintf or string concatenation to build the payload.
Instead, it issues precise, sequential io.WriteString calls directly into Go's underlying http.ResponseWriter buffer. There are no intermediate string allocations and no temporary byte slices. You are only asking the runtime to copy bytes into an existing buffer, and then flushing them to the socket. The heap remains quiet, even at volume.
Explicit concurrency
Keeping an SSE connection alive requires sending periodic comments (heartbeats) so proxies do not drop the idle TCP socket. Most libraries hide this in an unexported background worker.
sse makes it explicit:
// Start the heartbeat in the background
go emitter.ServeHeartbeats(r.Context(), 15*time.Second)
ServeHeartbeats is a blocking loop. You start it with go. It listens to the standard http.Request context and shuts down the exact millisecond the client disconnects. No silent leaks, no magic. The code does what it looks like it does. Predictability is a feature.
Consuming streams safely
The client side is just as mechanical. The Reader wraps an io.Reader. If you pass it an http.Response.Body that is already buffered, it detects it and avoids double-buffering.
reader := sse.NewReader(resp.Body)
for {
event, err := reader.Read()
if err != nil {
break
}
// Handle event
}
When the network context cancels, the underlying body closes, and Read unblocks instantly. We rely on the standard library rather than fighting it.
The reader parses the stream without loading entire payloads into memory. Unrecognized fields are safely tucked into the Extensions map. It ignores comments and strips the specification-mandated leading spaces. You get the events; the protocol details disappear.
No JSON magic
Many SSE libraries accept any and call json.Marshal internally. sse does not.
SSE is a text protocol. It does not care if your data is JSON, XML, or plain text. If you need JSON, you marshal it before assigning it to Event.Data. The library refuses to add the encoding/json dependency or force reflection costs on users who just want to send plain strings.
A Go implementation of RFC 7807: Problem Details for HTTP APIs
Ask five Go teams how they return a 422. You get five JSON shapes, two different field names for the human-readable message, and at least one service that sends text/plain. None of this is accidental. Each team made a decision that seemed fine at the time.
RFC 7807 defines a standard: a JSON object with a type URI, a status code, a title, a detail, and an optional instance. Any HTTP client that speaks application/problem+json gets a structured, machine-readable error contract without prior negotiation. The format has been final for years. What was missing was a Go implementation that treated it as a first-class HTTP citizen rather than a serialisation helper bolted onto an existing error type.
problemjson is that implementation. It models RFC 7807 Problem Details as an http.Handler, with functional composition and pre-built constructors for every standard HTTP status.
The usual answers fall short
The first answer most engineers reach for is http.Error. It writes a plain text body and sets the status code. The client receives "not found" with a text/plain content type and must decide what to do with it. There is no contract, and there is no structure.
The second answer is an ad-hoc JSON struct. Each service defines its own ErrorResponse, its own field names, its own conventions. Over time a codebase accumulates three or four of these, none compatible. Every consumer must discover the contract out-of-band — through documentation, source code, or trial and error. At scale that is a coordination problem wearing a technical costume.
The third answer is a framework's error middleware: a global handler that intercepts panics or sentinel values and transforms them. The framework owns the serialisation, the content type, the body shape. That works fine until you need to move a handler to a different service or mount it in a different framework. The error contract is not portable because it was never yours.
Errors as values
The central decision in problemjson is that *Problem implements http.Handler. A problem can be mounted on a mux, returned inline from a handler, or stored at the package level — anywhere a handler is expected. No adapter, no response wrapper, no special-case infrastructure.
// Mounted permanently — Instance is set from r.URL.Path on each request.
mux.Handle("/healthz", problemjson.ServiceUnavailable())
// Returned inline — the call site is identical to any other handler response.
func handleGet(w http.ResponseWriter, r *http.Request) {
if err != nil {
problemjson.NotFound(
problemjson.Detailf("user %d does not exist", id),
).ServeHTTP(w, r)
return
}
}
If you have only a ResponseWriter — middleware, tests, code without a request — call Write instead. It returns two errors. ErrFailedMarshalling means json.Marshal failed before anything hit the wire; a fallback response is still possible. ErrFailedWriting means the status line is already out; no HTTP recovery is possible. ServeHTTP handles the marshalling failure automatically and swallows the other. Call Write when you need to observe either.
if err := p.Write(w); err != nil {
if errors.Is(err, problemjson.ErrFailedMarshalling) {
http.Error(w, "fallback error", http.StatusInternalServerError)
}
// ErrFailedWriting: status is committed, nothing further can be sent.
}
Named errors without a registry
The "standard error shapes" problem — where do reusable error templates live — usually resolves one of two ways. Every handler constructs its own problem inline, which produces inconsistency across services. Or there is a central error package that every handler imports, which produces coupling. When the central package changes, every importer recompiles.
From offers a third option. Define a base problem once at the package level. Derive a concrete instance per request.
var ErrOutOfStock = problemjson.NotFound(
problemjson.Title("Product not available"),
)
func handleOrder(w http.ResponseWriter, r *http.Request) {
problemjson.From(ErrOutOfStock,
problemjson.Detailf("product %q is out of stock", productID),
problemjson.With("sku", productID),
).ServeHTTP(w, r)
}
From copies the base before applying options. The Extensions slice is deep-copied. A package-level base is safe across concurrent handlers; no call can corrupt it. The base lives with the domain code that owns it — a Go value, defined once, held in a var, no registration step. A shared mutable base would cost nothing until the race condition it introduces costs everything.
Extension fields appear at the top level of the JSON object, as RFC 7807 requires. With adds arbitrary key-value pairs. Error sets "reason" to err.Error(), keeping the underlying cause in the response body without putting it in the title or detail.
p := problemjson.New(
problemjson.Status(http.StatusUnprocessableEntity),
problemjson.Title("Validation failed"),
problemjson.With("field", "email"),
problemjson.With("constraint", "must be a valid email address"),
problemjson.Error(validationErr),
)
p.ServeHTTP(w, r)
// {"status":422,"title":"Validation failed","field":"email","constraint":"must be a valid email address","reason":"..."}
What was not built
Logging is not included. Whether a 500 writes to slog, zap, or a metrics counter is a handler concern. The package writes the HTTP response. You decide what else happens.
XML serialisation and application/problem+xml were considered and not built. RFC 7807 defines both content types, but Go HTTP APIs overwhelmingly use JSON. Adding XML adds a second serialisation path, a second content-type negotiation concern, and a second class of test surface. If you need XML problem responses, implement MarshalXML on top of the same struct.
Instance generation beyond r.URL.Path is not automatic. The spec says instance is a URI reference identifying the specific occurrence. What that means for your system — a trace ID, a request ID, a permanent URL — is a deployment concern. InstanceFromRequest sets it to the request path. Everything else is yours to compose.
Middleware factories were considered and not built. A function that wraps a handler and transforms panics into problem responses is one call to recover and one call to ServeHTTP. That is not a library problem.
Type URI validation is not performed. The spec requires a URI reference. The package stores whatever string you provide. Enforcing URI syntax at construction time would require importing net/url for a problem that is already caught by your API contract tests.
The full implementation is 2 files. Zero external dependencies. encoding/json, net/http, fmt, and errors are sufficient. Read the source in a sitting, vendor it, and own it outright. There is no upstream release cycle to subscribe to.
A minimalist WebSocket transport layer (RFC 6455 & RFC 7692) for Go.
The WebSocket protocol is fundamentally straightforward—a simple binary framing layer on top of a TCP stream. But when you look at mainstream Go libraries, they treat it like a full-blown application framework. They wrap your socket in asynchronous event loops, internal connection maps, and abstract messaging models, when all you really wanted was a way to read and write bytes efficiently.
By forcing you into their concurrency models, these libraries take control of your scheduling away from you.
The trap
The immediate cost of these heavy abstractions is heap churn. Most libraries allocate memory for every single frame header, construct scattered slices that trigger Go's escape analysis, and spin up hidden background goroutines to run automated ping/pong and heartbeat loops.
Streaming large payloads introduces a different set of compromises. Standard implementations either force you to buffer entire payloads into memory at once, or they rely on blind fragment chunking that ends up emitting a wasteful, empty trailing frame (FIN=true, Len=0) just to signal that a stream has concluded.
You don't need millions of users to feel the weight of this overhead. Even at low or medium traffic volumes, high heap churn introduces unpredictable tail latencies ($P_{99}$ spikes) whenever the Go garbage collector wakes up to sweep temporary objects. It also forces you to provision larger, more expensive cloud instances just to handle occasional payload bursts safely. Efficiency isn't just a optimization for internet-scale infrastructure; it's about making your software entirely predictable, clean to profile, and capable of running flawlessly on the cheapest hardware available.
Synchronous primitives and flat memory
This package provides synchronous, state-free building blocks. It strips away the background magic and leaves scheduling, resource management, and execution entirely under the control of the calling application.
Zero-Allocation Framing. Instead of generating temporary slices that escape to the heap, frame assembly happens inside a pre-allocated, contiguous scratchpad tied directly to the connection's lifecycle. Your data flows straight to the network card with 0 allocs/op.
// Initialize with pre-calculated constant boundaries
conn := websockets.NewConnection(netConn, websockets.ReadLimitStandard, websockets.ChunkSizeBalanced)
defer conn.Close()
// Keep read loops allocation-free by reusing a local slice
readBuf := make([]byte, 4096)
for {
payload, op, err := conn.ReadMessage(readBuf)
if err != nil {
return
}
// Echo data back down the fast path with zero heap churn
if err := conn.WriteMessage(op, payload); err != nil {
return
}
}
Lookahead Streaming. Both the raw and compressed streaming engines use a double-buffer lookahead sequence. By evaluating the finality of a data segment before framing it, messages conclude naturally on the final data frame.
func streamFile(ws *websockets.Conn, file io.Reader) error {
// Segments and flushes data in 4KB chunks.
// Ends naturally on the last byte with zero empty trailing frames.
return ws.StreamMessageExt(websockets.OpCodeBinary, 0x00, 4096, file)
}
Direct Dispatch Caching. At setup, the connection extracts and caches concrete pointer references for both *net.TCPConn and *tls.Conn. This allows the Go compiler to inline your write paths and execute static method calls, bypassing interface virtual table (vtable) lookups entirely for both unencrypted and natively encrypted traffic.
Extension without bloat
Features like permessage-deflate (RFC 7692) are decoupled as connection decorators. You only pay a CPU and memory tax for compression if it is explicitly negotiated during your handshake.
To maintain a zero-dependency footprint, the wrapper utilizes Go's pure-stdlib compress/flate engine out of the box. However, because it targets a lean Compressor interface, you can inject highly optimized assembly or SIMD-accelerated alternatives to push compression speeds past 1.5 GB/s:
// Initialize an external, hardware-accelerated compressor
klausCompressor, _ := flate.NewWriter(io.Discard, flate.BestSpeed)
// Inject it directly into the extension decorator
deflateConn := websockets.WrapDeflateWithCompressor(baseConn, klausCompressor)
defer deflateConn.Close()
What was not built
This package has no internal timers. No background keep-alive workers. No connection registries. No concurrency locks.
The scope is strictly limited to protocol execution. If you need to manage complex connection grouping, you build it on top. If you want a secure HTTP handshake handler that safely captures early client data and pipes it right into this engine, you pair it with a dedicated upgrade coordinator like cooper.
By focusing exclusively on bare-metal transport efficiency and standard library interoperability, the package gives you a WebSocket layer that operates predictably, allocates nothing on the hot path, and stays out of your way.
Design Philosophy
Zero Bloat
We rely on the Go standard library. Dependencies are treated as liabilities.
High Performance
Every allocation is intentional. Built for concurrency and high-throughput.
Idiomatic Go
No magic, no massive abstraction layers. Clean, predictable interfaces.
Contributing
Lowbit is open-source and forged by the community. Whether you're fixing bugs, improving docs, or proposing core features, we want your input. Please read our contribution guidelines before opening a Pull Request.