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.

MetricBeforeAfter
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

  1. A nice, fairly minimalist self-hosted push notification server. ↩

  2. That’s basically my approach to life, in a nutshell. In more ways than one. ↩

  3. A mechanism where a connection, once opened, is kept alive and reused for subsequent requests instead of being closed and reopened every time. ↩

  4. 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. ↩

  5. And apparently so does the model - except it, unlike me, has considerably more patience for reading library source code. ↩