Deep dive into notifyd internals — how the queue works, how SSE broadcasts, and why we made these choices.
┌─────────────────────────────────────────────────────────────────┐
│ notifyd │
│ │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ REST API │───→│ Queue │───→│ Connectors │ │
│ │ (Axum 0.7) │ │ (Postgres) │ │ │ │
│ └──────────────┘ └────────────┘ │ ┌──────────────┐ │ │
│ │ │ │ │ Email (Resend)│ │ │
│ │ │ │ └──────────────┘ │ │
│ ┌──────────────┐ ┌──────────────┐ │ ┌──────────────┐ │ │
│ │ SSE Broadcast│◄──→│ Worker │ │ │ SMS (Twilio) │ │ │
│ │(tokio channel)│ │ (tokio loop)│ │ └──────────────┘ │ │
│ └──────────────┘ └──────────────┘ │ ┌──────────────┐ │ │
│ │ │ Push (FCM) │ │ │
│ │ └──────────────┘ │ │
│ │ ┌──────────────┐ │ │
│ │ │ In-App (DB) │ │ │
│ │ └──────────────┘ │ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL 16 │ │
│ │ jobs │ subscribers │ inbox_messages │ templates │ ... │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Redis + BullMQ is the standard for job queues. But for a notification service doing ~1000 sends/minute (which covers 99% of self-hosted use cases), Postgres is more than fast enough — and you already have it.
SELECT FOR UPDATE SKIP LOCKED is the key. It's a Postgres feature that:
- Atomically claims rows for processing
- Skips rows already claimed by another worker
- Works like a distributed lock without a separate lock service
SELECT * FROM jobs
WHERE status IN ('pending', 'retry')
AND scheduled_at <= now()
ORDER BY scheduled_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKEDThis gives us:
- At-most-once processing per poll cycle
- Horizontal scaling — run multiple workers, they won't double-process
- Scheduling —
scheduled_atis just a WHERE clause - Persistence — jobs survive restarts (Redis can't guarantee this without AOF)
Failed jobs get exponential backoff:
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1 | 30 seconds | 30s |
| 2 | 2 minutes | 2m 30s |
| 3 | 10 minutes | 12m 30s |
After max_attempts (default 3), the job moves to failed status. Check via GET /v1/jobs/:id.
pending → processing → sent ✓
→ retry → processing → sent ✓
→ retry → ... → failed ✗
Cancelled via DELETE /v1/jobs/:id:
pending → cancelled ✓
scheduled → cancelled ✓
processing → (cannot cancel, already being sent)
| SSE | WebSocket | |
|---|---|---|
| Direction | Server → Client | Bidirectional |
| Complexity | EventSource (5 lines) |
ws library + reconnection logic |
| Proxy support | Works through all proxies | Some proxies break ws |
| Auto-reconnect | Built-in | Manual |
| Use case fit | Notifications (one-way) | Chat (two-way) |
Notifications are inherently one-way: server pushes to client. SSE is the simpler, more reliable choice.
┌─────────────────────┐
│ SseBroadcaster │
│ │
Worker ────────→│ HashMap<key, tx> │
(sends event) │ │
│ key = "project:sub"│
│ tx = broadcast::Tx │
└─────┬──────┬────────┘
│ │
┌────▼┐ ┌──▼───┐
│ rx1 │ │ rx2 │ (multiple tabs/devices)
│(SSE)│ │(SSE) │
└─────┘ └──────┘
- Client connects:
GET /v1/inbox/:sub_id/stream?token=xxx SseBroadcastercreates atokio::sync::broadcastchannel for this subscriber- When a notification is sent to this subscriber, the in-app connector calls
broadcaster.send() - All connected clients receive the event instantly
Cleanup: channels with no receivers are cleaned up periodically (every 2 minutes).
Putting a JWT in the URL (query param) is a security concern — it shows up in server logs, browser history, and referrer headers.
notifyd solves this with one-time tickets:
1. POST /v1/inbox/:sub_id/stream-ticket
→ {"ticket": "abc-123"} (valid 60 seconds)
2. GET /v1/inbox/:sub_id/stream?ticket=abc-123
→ Ticket consumed, stream starts
→ Same ticket can't be reused
Each connector implements a simple trait:
#[async_trait]
pub trait Connector: Send + Sync {
async fn send(&self, request: SendRequest) -> Result<()>;
}SendRequest contains:
to: recipient (email, phone number, subscriber ID)subject: optional (email)body: rendered body (template vars already substituted)payload: raw JSON for connector-specific fields
Simple HTTP POST to Resend API. Supports HTML (body_html) and plain text (body).
Swappable via config — change provider = "twilio" to provider = "telnyx" and it works. Same interface, different HTTP calls internally.
Firebase Cloud Messaging. Requires push token registration via POST /v1/push-tokens.
No external service. Writes to inbox_messages table and broadcasts via SseBroadcaster.
Lightweight event-driven workflows. Not Temporal — just what you need for notification sequences.
{
"id": "welcome-series",
"trigger_event": "user.signup",
"steps": [
{"type": "send", "channel": "email", "template": "welcome"},
{"type": "delay", "duration": "24h"},
{"type": "send", "channel": "email", "template": "getting_started"},
{"type": "delay", "duration": "72h"},
{"type": "condition", "check": "has_completed_onboarding", "if_false": [
{"type": "send", "channel": "email", "template": "nudge"}
]}
]
}- Event arrives (
POST /v1/workflows/trigger) - Engine finds matching workflows
- Creates a
workflow_runrecord - Executes steps sequentially
- On
delaysteps: saves state, marks run aspaused - Worker resumes paused runs when delay expires
State is persisted in Postgres — survives restarts. No in-memory state to lose.
notifyd is designed for running one instance across multiple projects (apps).
- Every table has
project_idas part of the primary key or foreign key - API keys are project-scoped
- Subscribers, templates, inbox — all isolated by project
- A subscriber
user-1in projectsquareis different fromuser-1in projectclozup
Zero-downtime key rotation:
POST /v1/admin/projects/:id/rotate-key— generates new key, old key still valid (24h grace)- Update your app to use the new key
POST /v1/admin/projects/:id/revoke-secondary— revoke old key
The pii.rs module masks sensitive data in logs:
- Email:
u***@example.com - Phone:
+336***678 - Names: first 2 chars +
***
Every API mutation is logged to audit_log:
- Who (API key / project)
- What (action)
- When (timestamp)
- From where (IP, request ID)
In-memory sliding window, per-project. Default 100 req/min. Configurable.
// Middleware checks before every request
if !rate_limiter.check(&project.id, project.rate_limit).await {
return Err(StatusCode::TOO_MANY_REQUESTS);
}| Metric | Value |
|---|---|
| Send throughput | ~500 notifications/second |
| Queue latency (enqueue→process) | <100ms |
| SSE connection overhead | ~2KB per connection |
| Memory footprint | ~15MB idle, ~50MB under load |
| Cold start | <2 seconds |
For most use cases (< 10K notifications/hour), a single instance is plenty.
If you need more:
- Run multiple instances pointing to the same Postgres (queue handles coordination)
- Scale Postgres reads with replicas
- For >100K/hour sustained, consider moving to Redis-backed queue (but you probably don't need this)