zig
notes (01)
From n8n Laziness to Stress-Testing an App in Zig
Context
It started with a simple need: relaying notifications from n8n on Coolify to ntfy1. The problem - I couldn’t figure out a sane way to save an n8n workflow to git (and didn’t feel like spending more time on it), so instead of fighting it any further, I wrote the thing myself, from scratch, in Zig2.
That’s how ntfy.zig came to be - a small relay: it takes in webhooks (Coolify and GitHub Actions/Deployments, for now), turns them into a title/message/priority, and forwards them to ntfy.
Months of vibe-coding can basically be summed up as the standard disclaimer under any LLM client - “use it, but verify” - which is why it seemed like a good excuse to put some QA experience to use and run stress tests with Locust.
Setup
One stress/ directory in the repo: a fake ntfy endpoint simulating latency/failure rate/timeouts, Locust mixing traffic from both webhook channels (Coolify + GitHub, including a deliberately wrong HMAC signature - the secret GitHub uses to sign the webhook payload so the recipient can verify the request actually came from GitHub - to test 401 rejection), and a script tying it all together, all inside a single uv environment on free-threaded Python 3.14t. Full technical details, including why free-threaded actually makes sense here, are in PR #14.
First run - something’s off
At 20 users everything looked great: median 5-6ms. I cranked it up to 300 users to find the ceiling and got:
OSError(49, "Can't assign requested address")
Uh oh. OSError 49 means the ephemeral port pool is exhausted - and that happened before the CPU even broke a sweat.
Root cause and fix
The cause: every single forward to ntfy built a brand new HTTP client from scratch and closed it immediately after. Which means a fresh TCP connection (and for https, a fresh TLS handshake) for every single webhook. Zero connection pooling3 - hence the rapid port exhaustion.
The fix: one http.Client built once at startup and shared across all requests4, instead of creating a new one for every forward. TCP connections start getting reused instead of multiplying endlessly.
verification
150 users, ~550 req/s. lsof on the ntfy.zig process showed 11 persistent connections to the fake ntfy instead of a new one per request. Zero port-exhaustion errors.
| Metric | Before | After |
|---|---|---|
| TCP connections at 150 VU / ~550 req/s | ~1 per request | ~11 persistent |
| Throughput (local) | ~70 req/s (wait_time-bound) | ~550 req/s |
Conclusion
The stress test didn’t need to be clever to catch a real bug - it was enough to push the user count high enough to hit the port limit instead of the CPU limit. It’s a strange world, honestly, where you can write and load-test an application in a language you don’t actually know5.
By the way: the whole app sits at around 2MB of RAM at rest - so on top of working correctly, it’s also pretty easy on resources.
Footnotes
-
A nice, fairly minimalist self-hosted push notification server. ↩
-
That’s basically my approach to life, in a nutshell. In more ways than one. ↩
-
A mechanism where a connection, once opened, is kept alive and reused for subsequent requests instead of being closed and reopened every time. ↩
-
context7 often has stale docs for niche libraries like Zig’s fresh-off-the-press stdlib - so Sonnet 5, working on the new
std.http, happily navigated straight through the library’s source code instead of relying on the (outdated) documentation. ↩ -
And apparently so does the model - except it, unlike me, has considerably more patience for reading library source code. ↩