fix: cap SSE connections per user and lengthen poll interval#1763
Merged
Priyanshu-byte-coder merged 1 commit intoMay 31, 2026
Merged
Conversation
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
|
@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. |
GSSoC Label Checklist 🏷️@Priyanshu-byte-coder — please apply the appropriate labels before merging: Difficulty (pick one):
Quality (optional):
Validation (required to score):
|
48a0154
into
Priyanshu-byte-coder:main
4 of 5 checks passed
|
🎉 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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1752
Problem
GET /api/streamcreates an independentsetIntervalfor every SSE connection. The interval fires every 2 seconds and executes 2 Supabase queries (one on thegoalstable, one onnotifications) per tick. There is no limit on how many connections a single user can hold open simultaneously.Concrete impact:
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:
GET /api/streamcreates its own interval with no knowledge of how many other connections already exist for that user.await. The abort handler that cleans up the interval was registered afterawait 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.tsMAX_CONNECTIONS_PER_USER = 4. Before opening a stream the handler checksactiveStreamConnectionsand returns 429 +Retry-After: 30if the cap is reached. Four slots cover normal multi-tab usage while eliminating unbounded accumulation.abortlistener. When the count reaches zero the map entry is deleted so memory is reclaimed.POLL_INTERVAL_MSfrom2 000ms to15 000ms — a 7.5× reduction in per-connection query frequency for data types that don't need second-level freshness.setIntervalandreq.signal.addEventListener("abort", …)to the synchronous portion of theReadableStreamstartcallback (before anyawait). This closes the race where abort fired before the listener was attached, which previously left intervals running indefinitely.src/lib/sse.tsactiveStreamConnections: Map<string, number>alongside the existingsseConnectionspush registry. The two maps serve distinct purposes and are kept separate sosendSSEEvent(used by webhook dispatch) is unaffected.Worst-case DB load comparison
Tests
test/sse-stream-route.test.ts— 15 new tests:All 15 new tests and all 7 existing
test/sse.test.tstests pass. The only failing test in the suite (test/dateUtils.test.ts— timezone boundary) is pre-existing and unrelated to this change.