Implementation of Chat Rate Limiting UX (Warn & Block)
Summary
The current lack of implementation throws a hard error. To improve this we have to implement a 2-stage feedback.
Stages
1. Throttle (slow_down)
- Trigger: reaching 80% of message limit in interval X — backend sends
slow_down { delayMs } - Goal: Regulate chat sending behavior before hitting the hard wall, without disrupting the user experience
- UX Behavior:
- When the user submits their next message while a slow_down is active, do not clear the input or send via WebSocket immediately
- Hold the outgoing WS message for
delayMsmilliseconds, then send it and clear the input - During this hold, replace the send icon with a
CircularProgressspinner on the send button (button remains interactive but submission is queued) - If
delayMsexpires before the user sends a new message, clear the slow_down state and resume normal behavior - The slow_down state is one-shot: it applies to the very next send action only
2. Block (too_many_requests)
- Trigger: Limit reached — backend sends
error { error: 'too_many_requests', retryAfterMs } - Goal: Clear communication — why sending is blocked and when it will resume
- UX Behavior:
- Restore the last sent message text back into the chat input field (message recovery)
- Disable the send button only — keep the textarea editable so the user can adjust the recovered message while waiting
- Hold the disabled state for
Math.max(retryAfterMs, 3000)ms, then re-enable - Display an inline error below the chat input for the same time period
Message Recovery (shared requirement)
- Always store the content of the last successfully dispatched message in Redux state (
lastSentMessage) - This is used by the Block stage to restore the message into the input after a
too_many_requestserror
Translations
| event | en | de |
|---|---|---|
too_many_requests |
Sending too fast! Try again in {n}s. | Du schreibst zu schnell! Versuche es in {n}s erneut. |
Technical Implementation
1. Add new incoming types — app/src/api/types/incoming/chat.ts
export interface SlowDown {
message: 'slow_down';
delayMs: number;
}
export interface TooManyRequests {
message: 'error';
retryAfterMs: number;
error: 'too_many_requests';
}
export type ChatMessage =
| MessageSent
| ChatEnabled
| ChatDisabled
| ClearGlobalChat
| RoomChatHistoryChunk
| PrivateChatHistoryChunk
| SearchResults
| SetLastSeenTimestamp
| SlowDown
| TooManyRequests;
2. Extend Redux state — chatSlice.ts
Add to ChatState:
slowDownDelayMs: number | null; // non-null while a slow_down is active
blockedUntil: number | null; // Date.now() + retryAfterMs (ms epoch), non-null while blocked
lastSentMessage: string | null; // content of last dispatched message
Add reducers:
-
setSlowDown(state, action: PayloadAction<number>)— setsslowDownDelayMs -
clearSlowDown(state)— clearsslowDownDelayMs -
setChatBlocked(state, action: PayloadAction<number>)— setsblockedUntil = Date.now() + Math.max(payload, 3000) -
clearChatBlocked(state)— clearsblockedUntil -
setLastSentMessage(state, action: PayloadAction<string>)— stores last sent content
Add selectors:
selectSlowDownDelayMsselectChatBlockedUntilselectLastSentMessage
3. Handle incoming messages — chat.ts
case 'slow_down':
dispatch(setSlowDown(data.delayMs));
break;
case 'error':
if (data.error === 'too_many_requests') {
notifications.error(i18next.t('chat-limiter-blocked'));
dispatch(setChatBlocked(data.retryAfterMs));
} else {
const dataString = JSON.stringify(data, null, 2);
log.error(`Unknown chat message type: ${dataString}`);
throw new Error(`Unknown message type: ${dataString}`);
}
break;
4. Update send flow — ChatForm.tsx
In onSubmit:
- Always dispatch
setLastSentMessage(values.message)before handling throttle/block logic - Read
slowDownDelayMsfrom Redux - If
slowDownDelayMsis set:- Do not dispatch
sendChatMessageimmediately - Keep the input populated, show spinner on send button
- After
slowDownDelayMsms, dispatchsendChatMessage, clear the input, and dispatchclearSlowDown
- Do not dispatch
- Otherwise, dispatch
sendChatMessagenormally
Acceptance Criteria
-
add
SlowDownandTooManyRequeststoincoming/chat.ts -
add
slowDownDelayMs,blockedUntilandlastSentMessagetoChatStatewith corresponding reducers and selectors - always persist the last sent message content to redux before submission
-
on
slow_down: queue and delay the next outgoing message bydelayMs, show spinner on send button, clear state if timer expires before next send -
on
too_many_requests: restore last sent message into the input, disable send button forMath.max(retryAfterMs, 3000)ms, show inline error below the chat form -
add
case 'slow_down'and extendcase 'error'tohandleChatMessage
Edited by Maximilian Fuß