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 delayMs milliseconds, then send it and clear the input
    • During this hold, replace the send icon with a CircularProgress spinner on the send button (button remains interactive but submission is queued)
    • If delayMs expires 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_requests error

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>) — sets slowDownDelayMs
  • clearSlowDown(state) — clears slowDownDelayMs
  • setChatBlocked(state, action: PayloadAction<number>) — sets blockedUntil = Date.now() + Math.max(payload, 3000)
  • clearChatBlocked(state) — clears blockedUntil
  • setLastSentMessage(state, action: PayloadAction<string>) — stores last sent content

Add selectors:

  • selectSlowDownDelayMs
  • selectChatBlockedUntil
  • selectLastSentMessage

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:

  1. Always dispatch setLastSentMessage(values.message) before handling throttle/block logic
  2. Read slowDownDelayMs from Redux
  3. If slowDownDelayMs is set:
    • Do not dispatch sendChatMessage immediately
    • Keep the input populated, show spinner on send button
    • After slowDownDelayMs ms, dispatch sendChatMessage, clear the input, and dispatch clearSlowDown
  4. Otherwise, dispatch sendChatMessage normally

Acceptance Criteria

  • add SlowDown and TooManyRequests to incoming/chat.ts
  • add slowDownDelayMs, blockedUntil and lastSentMessage to ChatState with 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 by delayMs, 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 for Math.max(retryAfterMs, 3000) ms, show inline error below the chat form
  • add case 'slow_down' and extend case 'error' to handleChatMessage
Edited by Maximilian Fuß