Skip to content

fix: throttle/debounce high-frequency WebSocket events to eliminate UI stuttering#73

Open
Pratyush-Panda-2006 wants to merge 5 commits into
suresh1319:masterfrom
Pratyush-Panda-2006:fix/issue-67-throttle-websocket-events
Open

fix: throttle/debounce high-frequency WebSocket events to eliminate UI stuttering#73
Pratyush-Panda-2006 wants to merge 5 commits into
suresh1319:masterfrom
Pratyush-Panda-2006:fix/issue-67-throttle-websocket-events

Conversation

@Pratyush-Panda-2006
Copy link
Copy Markdown
Contributor

Summary

Fixes #67 - Eliminates UI stuttering, frame drops, and high CPU usage caused by unthrottled real-time WebSocket events during multi-user collaborative sessions.

Problem

The client application emits state update payloads to the WebSocket server on every individual micro-change (keystrokes, cursor movements, awareness state changes). With multiple active users in a room, this creates a flood of network traffic that blocks the main thread and degrades the UI.

Solution

Implements a three-layer rate-limiting strategy across client and server:

1. Client - Cursor broadcasts (Editor.js)

  • Throttled at 50ms (~20 updates/sec) via createThrottle()
    • During fast arrow-key navigation or click-drag, cursor events fire 60+ times/sec - now capped to a smooth 20 fps

2. Client - Awareness-change DOM updates (Editor.js)

  • Throttled at 80ms - the awareness change handler rebuilds DOM bookmark widgets (cursor labels) for every peer
    • This was the most expensive per-event operation on the main thread

3. Client - Code-change state snapshots (Editor.js to EditorPage.js)

  • Debounced at 300ms via createDebounce() with flush() on unmount
    • The fileContentsRef (used for downloads/runs) is still updated synchronously
    • Only the React setState call (which triggers a re-render) is deferred

4. Server - Per-socket CODE_CHANGE rate limiter (server.js)

  • Sliding-window limiter: max 30 events/sec per socket
    • Excess events are silently dropped - the Yjs CRDT layer ensures eventual consistency

5. New utility - src/utils/throttle.js

  • Zero-dependency createThrottle() and createDebounce() factories
    • Proper cancel() and flush() methods to prevent memory leaks on component unmount

Files Changed

File Change
src/utils/throttle.js New - Reusable throttle/debounce utilities
src/components/Editor.js Throttle cursor and awareness; debounce code-change
src/pages/EditorPage.js Document debounced data flow
server.js Per-socket rate limiter on CODE_CHANGE events

Performance Impact

Metric Before After
Cursor WS frames/sec (fast typing) 60+ ~20
Awareness DOM rebuilds/sec 60+ ~12
React re-renders from code change Every keystroke Max 3/sec
Server CODE_CHANGE relay rate Unlimited Max 30/sec/socket

Testing

  • Production build passes (npx react-scripts build)
    • No new dependencies added
    • Pre-existing test failure is unrelated (landing page text mismatch)

…I stuttering (suresh1319#67)

Problem:
The client emits state updates on every individual micro-change
(keystrokes, cursor moves, awareness changes) without any rate
limiting. With multiple users, this floods the WebSocket channel
and causes main-thread blocking, UI stuttering, high CPU usage,
and frame drops.

Solution — three layers of rate limiting:

1. Client-side (Editor.js):
   - Throttle cursor-position awareness broadcasts (50ms / ~20 fps)
   - Throttle awareness-change DOM bookmark rebuilds (80ms)
   - Debounce code-change React state snapshots (300ms)

2. Parent component (EditorPage.js):
   - Document the debounced data flow from Editor → EditorPage

3. Server-side (server.js):
   - Per-socket sliding-window rate limiter on CODE_CHANGE events
     (max 30/sec). Excess events are silently dropped — the Yjs
     CRDT layer handles eventual consistency.

4. New utility (src/utils/throttle.js):
   - Zero-dependency throttle and debounce factories with proper
     cleanup (cancel/flush) to prevent memory leaks on unmount.

Closes suresh1319#67
Comment thread server.js Outdated
@entelligence-ai-pr-reviews
Copy link
Copy Markdown
Contributor


Confidence Score: 3/5 - Review Recommended

Likely safe but review recommended — the PR's goal of throttling/debouncing high-frequency WebSocket events to reduce UI stuttering is well-motivated, but the rate limiter implementation in server.js uses a fixed/tumbling window strategy while the documentation describes a sliding window, creating a correctness mismatch that could allow burst traffic to slip through at window boundaries and undermine the stated guarantees. This semantic gap between documented behavior and actual behavior means consumers of this API may build incorrect assumptions about rate limiting fairness and smoothness. The fix itself may be functionally adequate for many cases, but the documentation discrepancy should be resolved before merge to prevent future maintenance confusion.

Key Findings:

  • The rate limiter in server.js implements a fixed window (counter resets at each boundary) but is documented as a sliding window — this means up to 2x the intended rate limit could pass through at window edges, directly contradicting the throttling guarantees this PR is designed to enforce.
  • The PR's core approach of applying throttle/debounce to high-frequency WebSocket events is sound and addresses a real UI performance problem, but the correctness of the rate limiting mechanism is critical to achieving that goal reliably.
  • No critical or security issues were identified in the four reviewed files, and existing unresolved comments are absent, but the medium-impact correctness issue in server.js is substantive enough to warrant a review cycle before merging.
Files requiring special attention
  • server.js

@Pratyush-Panda-2006
Copy link
Copy Markdown
Contributor Author

Update regarding AI Review Bot Feedback

I have updated the PR description to accurately reflect the implementation details:

  • Corrected the server-side rate limiter description from "Sliding-window" to "Fixed/Tumbling window" to match the actual logic implemented in server.js.

The fixed-window implementation efficiently caps the CODE_CHANGE relay bursts and satisfies the performance goals without adding unnecessary complexity to the backend state. All automated checks are green, and the code is ready for review! 🚀

…x PERMISSION_DENIED bug (suresh1319#67)

- Enhanced createThrottle with leading+trailing edge semantics, input validation, and pending() introspection. - Enhanced createDebounce with input validation, pending(), and safe flush() on cleanup. - Added 19 focused unit tests covering edge cases, argument forwarding, cancel/flush lifecycle, and input validation. - Fixed PERMISSION_DENIED bug: added missing action constant to Actions.js and replaced raw error emits in server.js so permission-denial toasts actually reach the client. - Refined Editor.js cleanup ordering: flush code-change debounce before cancelling cursor/awareness throttles.
@entelligence-ai-pr-reviews
Copy link
Copy Markdown
Contributor

entelligence-ai-pr-reviews Bot commented May 19, 2026

EntelligenceAI PR Summary

Refactors error signaling in the file upload socket event handler to use a single communication channel, preventing double-toast UX regressions.

  • Removed duplicate socket.emit calls for PERMISSION_DENIED and INVALID_PAYLOAD error events in server.js
  • Errors are now exclusively communicated through the acknowledgment callback (reply)
  • Aligns with existing EditorPage.js ack handler behavior, which already displays error notifications to the user

Confidence Score: 3/5 - Review Recommended

Likely safe but review recommended — this PR makes a targeted and sensible fix by removing duplicate socket.emit calls for PERMISSION_DENIED and INVALID_PAYLOAD errors in server.js, consolidating error communication through the acknowledgment callback reply. The change aligns well with how EditorPage.js already handles ack-based error notifications. However, a pre-existing unresolved comment flags a MAJOR UX issue in EditorPage.js at L238-L257 where FS_UPLOAD_BATCH receiving an invalid nodes payload still triggers a double error toast — this is directly related to the socket error signaling pattern this PR is refactoring, making it a concern that should be resolved in context rather than deferred.

Key Findings:

  • The core change — removing redundant socket.emit for PERMISSION_DENIED and INVALID_PAYLOAD errors alongside the reply callback — correctly eliminates the double-toast regression and is logically sound.
  • An unresolved MAJOR review comment on EditorPage.js:L238-L257 indicates that the FS_UPLOAD_BATCH invalid payload path still causes double error toasts, which is directly in scope for this PR's stated goal of fixing double-toast UX regressions — leaving it unresolved undermines the PR's own objective.
  • No new issues were introduced by this PR itself; the current review found no additional problems, meaning the only concern is the pre-existing unresolved comment that is thematically tied to this change.
Files requiring special attention
  • src/pages/EditorPage.js
  • server.js

Comment thread server.js Outdated
Comment thread src/components/Editor.js Outdated
@Pratyush-Panda-2006
Copy link
Copy Markdown
Contributor Author

Hardened createThrottle / createDebounce utilities (src/utils/throttle.js)

  • Added input validation -- TypeError on invalid fn or delay arguments
    • Added pending() introspection method on both utilities (returns true when a trailing call is queued)
    • Enhanced JSDoc with design-decision documentation explaining the leading+trailing edge strategy

Comprehensive test suite (src/utils/throttle.test.js) -- 19 tests

  • Leading/trailing edge semantics
    • Argument forwarding (variadic ...args)
    • cancel() / flush() / pending() lifecycle
    • Re-usability after cancel/flush
    • Zero-delay edge case
    • Input validation (TypeError on bad arguments)

Fixed PERMISSION_DENIED bug (src/Actions.js + server.js)

  • The client was already listening on ACTIONS.PERMISSION_DENIED but the constant was never defined in Actions.js -- meaning permission-denial toasts never fired
    • The server was emitting the raw 'error' event which clashes with Socket.IO's reserved event name
    • Added the missing constant and replaced all socket.emit('error', ...) -> socket.emit(ACTIONS.PERMISSION_DENIED, ...) to close the loop end-to-end

Refined Editor.js cleanup ordering

  • Flush code-change debounce first (to capture latest content for downloads), then cancel cursor/awareness throttles
    • Added explicit null-ref guards on all rate-limiter refs during cleanup
      Verification:
      npm.cmd test -- --runTestsByPath src/utils/throttle.test.js --runInBand --watchAll=false
      -> 19 passed, 19 total

node --check src/components/Editor.js -> ok
node --check server.js -> ok

…iding-window rate limiter, remove dead import

- Removed unused useMemo import from Editor.js (maintainability nit). - Added ACTIONS.INVALID_PAYLOAD constant in Actions.js — semantically separates input-validation errors from auth/authz rejections so client handlers can distinguish between the two. - Changed server.js !Array.isArray(nodes) check to emit INVALID_PAYLOAD instead of PERMISSION_DENIED — fixes the correctness issue where a malformed request was mislabeled as a permission failure. - Replaced fixed-window rate limiter in server.js with a true sliding-window implementation using a timestamp queue — the old approach allowed up to 2x the limit at window boundaries. - Added INVALID_PAYLOAD listener and cleanup in EditorPage.js so malformed-request toasts are surfaced to the user.
Comment thread src/pages/EditorPage.js
Comment on lines +245 to +254
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.error(message);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR UX Double error toast when FS_UPLOAD_BATCH receives invalid nodes payload

server.js:205-208 emits INVALID_PAYLOAD AND calls reply(false, …) for the same validation failure. EditorPage.js handles both: the INVALID_PAYLOAD socket listener (line 245) fires toast.error, and the FS_UPLOAD_BATCH ack callback (line 567) also fires toast.error — showing two identical toasts.

Suggested change
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.error(message);
});
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
// Only handle INVALID_PAYLOAD events that are NOT from FS_UPLOAD_BATCH,
// which already surfaces the error via its ack callback.
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.warn(message);
});
Prompt to fix with AI

Copy this prompt into your AI coding assistant to fix this issue.

In server.js the FS_UPLOAD_BATCH invalid-nodes branch (line 205-208) fires both a socket.emit(ACTIONS.INVALID_PAYLOAD, …) and reply(false, …). In EditorPage.js both the new INVALID_PAYLOAD listener (line 245) and the FS_UPLOAD_BATCH ack callback (line 563-568) call toast.error, so the user sees two toasts. Fix by removing socket.emit(ACTIONS.INVALID_PAYLOAD, …) from the FS_UPLOAD_BATCH handler in server.js and relying solely on the ack reply to surface this error, keeping INVALID_PAYLOAD reserved for fire-and-forget events that have no ack channel.

@suresh1319
Copy link
Copy Markdown
Owner

EntelligenceAI PR Summary

This PR fixes a rate limiter boundary-burst vulnerability, introduces a semantic INVALID_PAYLOAD action, and wires up the corresponding client-side error handling.

  • Replaced fixed-window rate limiter in server.js with a sliding-window approach using a per-socket timestamp queue (socketRateTimestamps) to prevent up to 2× limit bursts at window boundaries
  • Added INVALID_PAYLOAD: 'invalid_payload' constant to src/Actions.js to semantically distinguish input-validation failures from authorization failures
  • Corrected server.js upload error response from ACTIONS.PERMISSION_DENIED to ACTIONS.INVALID_PAYLOAD
  • Added INVALID_PAYLOAD socket event listener in src/pages/EditorPage.js with toast notification and proper effect cleanup to prevent memory leaks
  • Removed unused useMemo import in src/components/Editor.js

Confidence Score: 3/5 - Review Recommended

Likely safe but review recommended — this PR introduces meaningful improvements (sliding-window rate limiting in server.js, the new INVALID_PAYLOAD action constant in src/Actions.js) but has a concrete UX bug introduced by the changes themselves: server.js lines 205-208 both emit INVALID_PAYLOAD to the socket and calls reply(false, …) for the same FS_UPLOAD_BATCH validation failure, causing EditorPage.js to render a double error toast to the user. The core logic improvements are sound, but this dual-signal path for a single error condition should be resolved before merging to avoid a degraded user experience that is directly attributable to this PR.

Key Findings:

  • In server.js (lines 205-208), a single invalid-payload condition on FS_UPLOAD_BATCH triggers both an INVALID_PAYLOAD socket emission and a reply(false, …) callback — EditorPage.js listens to both independently, resulting in two error toasts shown to the user for one failure event. This is a medium-impact UX regression introduced by this PR.
  • The sliding-window rate limiter using socketRateTimestamps is a genuine correctness improvement over the fixed-window approach, closing a real 2× burst vulnerability at window boundaries without apparent logic flaws.
  • The new INVALID_PAYLOAD: 'invalid_payload' constant in src/Actions.js is a clean semantic addition, but its wiring in EditorPage.js and server.js needs to be audited to ensure exactly one error path is taken per failure, not both the event emission and the reply callback.

Files requiring special attention

@Pratyush-Panda-2006 please refer to this

…double toasts

- Remove socket.emit(PERMISSION_DENIED) from upload permission check, keeping only the ack reply callback. EditorPage.js ack handler already shows an error toast, so emitting both caused duplicate toast notifications.

- Remove socket.emit(INVALID_PAYLOAD) from upload payload validation, keeping only the ack reply callback. Same dual-signal issue caused a double-toast UX regression for invalid payloads.

Both error paths now use exactly one signal (the ack callback) per failure, ensuring the user sees a single, clear error toast.
@Pratyush-Panda-2006
Copy link
Copy Markdown
Contributor Author

@suresh1319 fixed it

@suresh1319
Copy link
Copy Markdown
Owner

@Pratyush-Panda-2006 solve the conflicts

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[GSSoC'26] UI Performance Lag and High CPU Usage from Unthrottled Real-Time Input Events

2 participants