notifyd

Logo

Self-hosted notification server. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox. Operated by your agent over MCP. Open source, MIT.

View the Project on GitHub rmzlb/notifyd

Benchmarks

Numbers you can reproduce, measured on 2026-09-05 against commit 679c211 plus the batch-insert / batch-context work shipped right after it. All figures come from a single notifyd process talking to one PostgreSQL 16 container on the same host. Nothing was tuned in Postgres.

Test bench

   
Host 8 vCPU (Arm Neoverse-V2), 30 GB RAM, Ubuntu, Docker
Postgres 16, default postgresql.conf, one container, same host
notifyd release build, DATABASE_MAX_CONNECTIONS=20, WORKER_BATCH_SIZE=500, WORKER_POLL_INTERVAL_MS=100, EMAIL_RATE_PER_SEC=0 (pacer off)
Provider EMAIL_PROVIDER=log with a batch size of 100, i.e. the exact code path Resend takes (claim → build → batch → finalize → webhooks) minus the network
Load one project with an unlimited rate limit, 100 000 email jobs enqueued with scheduled_at in the future, released in a single UPDATE, drained by one worker

The log provider is a no-op, so the drain numbers measure notifyd and Postgres, not Resend. On a real provider the ceiling is the provider’s quota (Resend: 100 recipients per batch call, 2 requests/s by default) and the pacer will hold the line there; see docs/CONNECTORS.md.

Compared with Novu, measured on the same machine

Measured on 2026-09-10 on the same 8 vCPU / 30 GB Linux host, with the same tool (docker stats --no-stream, i.e. cgroup memory minus page cache), both stacks idle: started, healthy, no traffic. Novu is the community docker/community/docker-compose.yml of novuhq/novu at tag 3.19.0 with the secrets its setup.sh generates; host port mappings were removed so it could run isolated (no effect on memory). Two samples at 6 and 9 minutes, identical to the megabyte.

  Novu 3.19.0 (community compose) notifyd 0.4.0
Containers 6 (api, worker, ws, dashboard, MongoDB, Redis) 1 (+ the Postgres you already run)
Images to pull (compressed, what docker pull downloads) 1 393 MB (api 346, worker 325, ws 318, dashboard 91, mongo 276, redis 37) 13.5 MB (ghcr.io/rmzlb/notifyd:0.4.0, amd64 manifest; + 40 MB for postgres:16-alpine if you need one)
Image on disk once pulled not re-measured (Node images unpack to two to three times their pull size) 30.7 MB (sum of docker history layers: 9.2 MB Alpine, 4.8 MB ca-certificates and wget, 16.6 MB static binary)
Memory at idle, docker stats 1 116 MB (api 425, worker 277, ws 269, mongodb 118, dashboard 17, redis 8) 1.9 MB for notifyd, 55 MB for its Postgres
Memory at idle, process RSS (/proc/<pid>/status) not sampled 9.5 MB (2026-09-15, arm64 host, after 60 s idle); 13 MB on 2026-09-10 (0.2.2)

The Novu column is from 2026-09-10; the notifyd column was re-measured on 2026-09-15 with the published 0.4.0 image, on the same host. The 44 MB image size quoted here until then came from a locally built image reported by the containerd image store, which is not the size anyone downloads; the pull size above is what docker pull transfers and is the figure comparable with the Novu column (also pull sizes).

Two remarks so this stays fair. Novu ships a full web dashboard and an in-app widget server in those containers; notifyd ships neither by design (an API, a digest, MCP tools). And this is an idle footprint, not a throughput comparison: we did not load-test Novu. The numbers are here because a self-hoster’s first question is “what will this cost me to run”, and the answer differs by two orders of magnitude.

Reproduce: git clone --depth 1 https://github.com/novuhq/novu, follow docker/community/setup.sh, wait for healthy, run docker stats --no-stream and docker image inspect --format '' on the six images (for pulled images the containerd store reports the compressed size). For notifyd: docker run the published image against a postgres:16-alpine, wait for /v1/health, then docker stats --no-stream, docker history and grep VmRSS /proc/$(docker top <container> -o pid | tail -1)/status.

Footprint

Metric Value
Release binary (stripped) 12 MB glibc (cargo build --release), 16 MB static musl (the image) — measured 2026-09-10
Docker image (alpine:3.22 runtime) 13.5 MB to pull, 30.7 MB on disk (0.4.0, 2026-09-15). The 44 MB quoted on 2026-09-10 was a local build as reported by the containerd store
RSS at idle (worker polling, SSE hub up) 13 MB (2026-09-10, 0.2.2); 9.5 MB (2026-09-15, 0.4.0, arm64)
RSS peak while draining 100 000 jobs 23 MB
RSS peak while accepting 50 000 jobs through /v1/batch 29 MB
Postgres jobs table + indexes for 100 000 sent jobs 84 MB (≈ 0.85 kB per job, body included)
Rust source ~12 000 lines, 14 migrations, no unsafe

The process does not grow with the queue: jobs live in Postgres, the worker holds at most one claimed batch in memory.

Throughput

Path Result
POST /v1/send (HTTP round-trip incl. auth, rate limit, insert), 8 concurrent clients 1 867 req/s, p50 3.9 ms, p99 < 15 ms
POST /v1/batch, 5 000 subscribers per call (one INSERT … SELECT FROM unnest) 44 500 jobs/s enqueued
Worker drain, batching provider (100 per call) 3 537 jobs/s sustained over 100 000 jobs
Worker drain, non-batching provider (1 per call, e.g. SMTP, Twilio) 640 jobs/s
GET /v1/admin/digest over 100 000 jobs 70 ms

Before the set-based insert, /v1/batch ran at 719 jobs/s (one INSERT per subscriber) and the drain at 516 jobs/s (per-job sender/suppression/preference lookups and one webhook task per job). The commit that follows 679c211 replaced those with one statement per batch: senders, suppressions and preferences are fetched once per claimed batch, jobs are claimed and finalised with ANY($1) / unnest, and webhook fan-out is skipped entirely for projects without a webhook.

What this means in practice

How the numbers compare

Are we biased? Probably, yes: this page is written by the people who wrote notifyd. So it only publishes numbers we measured ourselves, on notifyd. We have not run Novu, Knock, Courier or SuprSend through this protocol, and the hosted products cannot be measured this way at all. What can be compared without a benchmark is the shape of each system:

  notifyd Novu (self-hosted) Knock / Courier / SuprSend
Runtime 1 container, Postgres API, worker, WebSocket and web containers, MongoDB, Redis hosted SaaS
Queue durability Postgres row per job, SKIP LOCKED Redis / BullMQ managed
Provider 429 pauses the channel for Retry-After, retries without consuming an attempt job fails (one attempt) managed
Ops surface REST digest, MCP tools, Prometheus React dashboard dashboard + API

If you run Novu (or anything else) through the protocol below on the same class of hardware, open an issue with the command lines and the numbers and we will link it here, whatever they say.

Reproduce

# 1. Scratch database and a release binary
createdb notifyd_bench
cargo build --release

# 2. Run notifyd with a no-op provider and the pacer off
DATABASE_URL=postgres://.../notifyd_bench JWT_SECRET=... ADMIN_API_KEY=bench \
EMAIL_PROVIDER=log EMAIL_FROM=bench@example.invalid \
WORKER_BATCH_SIZE=500 WORKER_POLL_INTERVAL_MS=100 EMAIL_RATE_PER_SEC=0 \
DATABASE_MAX_CONNECTIONS=20 RUST_LOG=notifyd=warn ./target/release/notifyd

# 3. Project without rate limit, 20 × 5 000 jobs scheduled in the future
curl -s -X POST -H "x-api-key: bench" -H 'content-type: application/json' \
  -d '{"id":"bench","name":"Bench","channels":["email"],"rate_limit_per_min":100000000}' \
  http://localhost:3400/v1/admin/projects
# then POST /v1/batch with {"channel":"email","subscribers":[...5000 ids...],
#   "subject":"bench","body":"x","scheduled_at":"2030-01-01T00:00:00Z"} × 20

# 4. Release everything at once and time the drain
psql notifyd_bench -c "UPDATE jobs SET scheduled_at = now() WHERE status = 'pending'"
# poll: SELECT count(*) FROM jobs WHERE status IN ('pending','processing','retry')
# RSS:  ps -o rss= -p <pid>