Skip to content

fix: cap SSE connections per user and lengthen poll interval#1763

Merged
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/sse-connection-limits
May 31, 2026
Merged

fix: cap SSE connections per user and lengthen poll interval#1763
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/sse-connection-limits

Conversation

@Ridanshi
Copy link
Copy Markdown
Contributor

Closes #1752

Problem

GET /api/stream creates an independent setInterval for every SSE connection. The interval fires every 2 seconds and executes 2 Supabase queries (one on the goals table, one on notifications) per tick. There is no limit on how many connections a single user can hold open simultaneously.

Concrete impact:

Concurrent tabs DB queries / second (per user)
1 1
5 5
10 10
N N

Resource usage scales linearly. A user with 10 browser tabs — or a browser that reconnects repeatedly due to network flaps — generates 10 polling loops and 20 DB queries per second attributed to a single account. This was confirmed by reading the production code in src/app/api/stream/route.ts.

Root cause

Three compounding problems:

  1. No per-user connection tracking. Every accepted GET /api/stream creates its own interval with no knowledge of how many other connections already exist for that user.
  2. 2 s poll interval. The data polled (goal sync timestamps and unread notification counts) does not change at sub-second granularity, so the frequency is unnecessary.
  3. Abort listener registered after an await. The abort handler that cleans up the interval was registered after await checkData(), creating a race: if the request was aborted before that await resolved, the interval was never cleared (orphaned polling loop).

Fix

src/app/api/stream/route.ts

  • Adds MAX_CONNECTIONS_PER_USER = 4. Before opening a stream the handler checks activeStreamConnections and returns 429 + Retry-After: 30 if the cap is reached. Four slots cover normal multi-tab usage while eliminating unbounded accumulation.
  • Increments the counter on accept, decrements it in the abort listener. When the count reaches zero the map entry is deleted so memory is reclaimed.
  • Increases POLL_INTERVAL_MS from 2 000 ms to 15 000 ms — a 7.5× reduction in per-connection query frequency for data types that don't need second-level freshness.
  • Moves both setInterval and req.signal.addEventListener("abort", …) to the synchronous portion of the ReadableStream start callback (before any await). This closes the race where abort fired before the listener was attached, which previously left intervals running indefinitely.

src/lib/sse.ts

  • Exports activeStreamConnections: Map<string, number> alongside the existing sseConnections push registry. The two maps serve distinct purposes and are kept separate so sendSSEEvent (used by webhook dispatch) is unaffected.

Worst-case DB load comparison

Scenario Before After
1 tab 1 q/s 0.13 q/s
4 tabs (cap) 4 q/s 0.53 q/s
5th tab 5 q/s rejected (429)
N tabs N q/s ≤ 0.53 q/s

Tests

test/sse-stream-route.test.ts — 15 new tests:

Category Cases
Auth guards no session, missing githubId, unresolved user
Single connection 200 + SSE headers, counter incremented to 1
Connection-limit enforcement 5th connection → 429, Retry-After header present, counter stays at 4
Cross-user isolation cap on user-1 does not block user-2
Cleanup abort decrements counter; last-connection close removes map entry; partial close leaves remaining count
Response headers Content-Type, Cache-Control, Connection

All 15 new tests and all 7 existing test/sse.test.ts tests pass. The only failing test in the suite (test/dateUtils.test.ts — timezone boundary) is pre-existing and unrelated to this change.

Each GET /api/stream previously created an independent setInterval that
fired every 2 s and executed 2 Supabase queries (goals + notifications).
With no per-user limit, every additional browser tab produced 2 more
queries per second — resource usage scaled linearly with connection count.

Changes:

src/app/api/stream/route.ts
- Introduce MAX_CONNECTIONS_PER_USER = 4: once a user already has 4 open
  streams the next request receives 429 with a Retry-After header. This
  caps worst-case DB load at 4 connections × 2 queries / 15 s per user
  instead of N × 2 queries / 2 s.
- Import and update activeStreamConnections (new export from sse.ts) on
  every connect/disconnect to maintain the per-user counter.
- Increase poll interval from 2 000 ms to 15 000 ms. Goal sync timestamps
  and unread notification counts do not require sub-second resolution;
  15 s keeps updates prompt while cutting query frequency by 87.5%.
- Register the setInterval and req.signal abort listener synchronously at
  the top of the ReadableStream start callback (before any await) to
  eliminate a race where abort() fires before the handler is attached.
  The abort handler decrements the counter and removes the map entry when
  the last connection for a user closes.

src/lib/sse.ts
- Export activeStreamConnections Map<string, number> alongside the
  existing sseConnections push registry. Separating the two concerns
  keeps the push path (sendSSEEvent) unchanged while giving the stream
  route a shared, testable counter.

test/sse-stream-route.test.ts — 15 new tests:
- Auth guards (no session, no githubId, unresolved user)
- Single connection: 200 response, correct headers, counter increment
- Connection-limit enforcement: 5th connection → 429, Retry-After
  present, counter does not increase beyond cap
- Cross-user isolation: cap for user-1 does not block user-2
- Cleanup: abort decrements counter, last-connection removal deletes
  the map entry, partial close leaves remaining count intact
- Response headers: Content-Type, Cache-Control, Connection

Closes Priyanshu-byte-coder#1752
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

@Ridanshi is attempting to deploy a commit to the PRIYANSHU DOSHI's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added gssoc26 GSSoC 2026 contribution type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts) labels May 31, 2026
@github-actions
Copy link
Copy Markdown

GSSoC Label Checklist 🏷️

@Priyanshu-byte-coder — please apply the appropriate labels before merging:

Difficulty (pick one):

  • level:beginner — 20 pts
  • level:intermediate — 35 pts
  • level:advanced — 55 pts
  • level:critical — 80 pts

Quality (optional):

  • quality:clean — ×1.2 multiplier
  • quality:exceptional — ×1.5 multiplier

Validation (required to score):

  • gssoc:approved — counts for points
  • gssoc:invalid / gssoc:spam / gssoc:ai-slop — does not score

Type labels (type:*) are auto-detected from files and title. Review and adjust if needed.
Points formula: (difficulty × quality_multiplier) + type_bonus

@Priyanshu-byte-coder Priyanshu-byte-coder added level2 GSSoC Level 2 - Medium complexity (25 points) gssoc:approved GSSoC: PR approved for scoring labels May 31, 2026
@Priyanshu-byte-coder Priyanshu-byte-coder merged commit 48a0154 into Priyanshu-byte-coder:main May 31, 2026
4 of 5 checks passed
@github-actions
Copy link
Copy Markdown

🎉 Merged! Thanks for contributing to DevTrack.

If the project has been useful to you, a ⭐ star on the repo is the easiest way to support it — it helps DevTrack get discovered by more developers.

Keep an eye on open issues for your next contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved GSSoC: PR approved for scoring gssoc26 GSSoC 2026 contribution level2 GSSoC Level 2 - Medium complexity (25 points) type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Server-Sent Events endpoint can generate excessive database load because each connection continuously polls without connection limits

2 participants