A production grade API uptime monitoring system. Register your API endpoints, configure check intervals, and get realtime alerts when something goes wrong with automatic resolution when services recover.
Built with Node.js, TypeScript, Fastify, PostgreSQL, Redis, BullMQ, and Next.js.
- Scheduled ping engine: configurable check intervals from 10 seconds to 24 hours per monitor
- Realtime dashboard: live status updates via Server Sent Events, no polling
- Smart alerting : alerts fire after 3 consecutive failures to eliminate false alarms; auto resolve on recovery
- Full check history : paginated check log with date range filtering and response time tracking
- Failure classification: distinguishes between timeouts, connection refused, and unexpected status codes
- JWT authentication: access + refresh token pair with Redis backed token blocklist on logout
- Monitor limits: atomic enforcement of per user monitor caps via single INSERT...SELECT query
Solid paths = synchronous (within a single request). Dashed paths = asynchronous (worker runs on BullMQ schedule independently of the API).
| Layer | Technology | Why |
|---|---|---|
| Backend framework | Fastify | ~2x throughput vs Express, first class TypeScript, encapsulated plugin system |
| Database | PostgreSQL | Relational schema with composite unique constraints, window functions for pagination |
| Job queue | BullMQ + Redis | Persistent repeatable jobs, at least once delivery, horizontal worker scaling |
| Real time | Server Sent Events | Serverpush only dashboard — SSE is simpler and lower overhead than WebSockets |
| Auth | JWT + Redis blocklist | Stateless access tokens (15m), refresh tokens (7d), JTI based revocation on logout |
| Frontend | Next.js (App Router) | Fetch based SSE client for Authorization header support, CSS Modules for scoped styles |
| Language | TypeScript | End to end type safety from DB queries to API responses |
users — id, email, username, password_hash, created_at, updated_at, deleted_at
monitors — id, user_id, name, url, check_interval_seconds, expected_status_code, is_active
checks — id, monitor_id, status (up|down), response_time_ms, status_code_received, failure_reason, checked_at
alerts — id, monitor_id, triggered_at, resolved_atKey design decisions:
CONSTRAINT unique_user_url UNIQUE (user_id, url): one user cannot register the same URL twice, but different users can monitor the same URLresponse_time_msandstatus_code_receivedare nullable:NULLmeans the request timed out, no response received- Soft delete on
users(deleted_at): preserves audit history, allows account recovery - Composite index on
checks(monitor_id, checked_at): the most queried columns for dashboard history - Trigger function auto updates
updated_aton row change: DB clock, not application clock
setInterval lives in process memory. A server restart loses all schedules. BullMQ stores jobs in Redis with deterministic job IDs (monitor:${monitorId}), so schedules survive restarts and can be distributed across multiple worker processes.
The dashboard is read only realtime data. The server pushes status updates, the client never sends data back through the realtime channel. SSE is unidirectional server push over a persistent HTTP connection. WebSockets would add full duplex overhead for no benefit.
The browser's native EventSource API doesn't support custom headers, so we use fetch + response.body.getReader() to stream SSE with a Bearer token.
A single failed check could be a transient network blip. Alerting on every failure would produce false positives and alert fatigue. The worker queries the last 3 checks ordered by checked_at DESC; if all are down, an alert fires. A second guard checks for an existing open alert before inserting; preventing duplicate alerts during extended downtime.
The worker always writes in this order: check row → alert row → SSE push. The consecutive check query reads from the checks table. The current check must exist before querying. The SSE push is always last so the dashboard reflects already persisted state, never an in-memory state that hasn't been committed.
JWT is stateless, there is no server side session to destroy. On logout, both the access token and refresh token JTIs are stored in Redis with TTL equal to their remaining validity (SET blocklist:${jti} 1 EX ${remainingTtl}). The auth middleware checks the blocklist on every request. Redis failures return 503 - we fail closed.
At 1,000 monitors with 60-second intervals: 1.44 million rows/day. At minimum intervals (10s) with 10 monitors per user and 1,000 users: 86.4 million rows/day.
Current implementation uses a flat table with retention policy. At scale:
- Partition by date:
DROP PARTITIONis a metadata operation; row by rowDELETEcauses table bloat and WAL pressure at this volume - Tiered retention: sub-minute intervals keep 7 days; longer intervals keep 90 days
- Downsampling: after 24h, aggregate raw checks into hourly summaries (avg latency, uptime %). Store the summary, drop the raws
BullMQ workers are stateless and read from the same Redis queue. Horizontal scaling is adding more worker processes, no coordination needed. Current bottleneck at scale: Redis sorted set throughput for job scheduling. Mitigation: shard queues by monitor ID range.
Current limitation: a single-region deployment cannot distinguish "API is globally down" from "API is unreachable from this location." Planned architecture:
- Regional worker agents (US, EU, Asia) each with their own BullMQ queue
checks.regioncolumn records which region performed the check- User-configurable alert threshold: any region down / majority down / all regions down
- Regional ingestion with async aggregation to avoid cross-region write latency
At scale, postgres.js default pool of 10 connections becomes a bottleneck. Mitigation: PgBouncer in transaction pooling mode in front of PostgreSQL.
- Docker and Docker Compose
- Node.js 20+
git clone https://github.com/wavellen/uptime-pulse
cd uptime-pulse
# Start PostgreSQL and Redis
docker compose up -d
# Backend
cd backend
cp .env.example .env
npm install
npm run migrate
npm run dev # starts on port 3000
# Frontend (separate terminal)
cd ../frontend
npm install
npm run dev # starts on port 3001DATABASE_URL=postgres://user:password@localhost:5432/api_monitor
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-to-a-random-secret
JWT_REFRESH_SECRET=change-me-to-another-random-secret
PORT=3000
NODE_ENV=developmentPOST /auth/register { email, username, password }
POST /auth/login { email, password } → { accessToken, refreshToken }
POST /auth/logout Authorization: Bearer <token>, body: { refreshToken }
POST /auth/refresh { refreshToken } → { accessToken, refreshToken }
GET /monitors → MonitorResponse[]
GET /monitors/:id → MonitorResponse
POST /monitors { name, url, check_interval_seconds, expected_status_code }
PATCH /monitors/:id { name?, url?, check_interval_seconds?, expected_status_code?, is_active? }
DELETE /monitors/:id
GET /monitors/:id/stream SSE stream — real-time check results
GET /monitors/:id/checks ?page=1&limit=50&from=ISO&to=ISO → PaginatedChecks
GET /monitors/:id/checks/:checkId → Check
GET /monitors/:id/alerts ?page=1&limit=50&from=ISO&to=ISO → PaginatedAlerts
GET /monitors/:id/alerts/:alertId → Alert
PATCH /monitors/:id/alerts/:alertId → resolves the alert
All routes except /auth/register, /auth/login, and /health require Authorization: Bearer <accessToken>.
uptime-pulse/
├── backend/
│ └── src/
│ ├── config/ env, db, redis connections
│ ├── routes/ URL definitions → controllers
│ ├── controllers/ request/response handling
│ ├── services/ business logic + DB queries
│ ├── workers/ BullMQ worker (ping engine)
│ ├── queues/ BullMQ queue + job scheduler
│ ├── sse/ SSE connection manager
│ ├── middleware/ auth guard, error handler
│ ├── types/ TypeScript interfaces
│ ├── migrations/ SQL migration files
│ └── utils/ shared error handling
└── frontend/
└── app/
├── (auth)/ login, register pages
└── (dashboard)/ monitor list, monitor detail
MIT