Front-end Engineering Lab

Rate Limiting (Client-side)

Prevent multiple rapid submissions of critical actions to avoid accidental or malicious abuse.

Rate Limiting (Client-side)

The Risk

Without rate limiting:

  • Double submissions (user clicks twice)
  • Accidental abuse (spam clicking)
  • Malicious abuse (automated attacks)
  • Poor UX (duplicate orders, payments)

Solution

Implement client-side throttling for critical actions.

/**
 * Rate limiter for buttons
 */
class ButtonRateLimiter {
  private lastClick: Map<string, number> = new Map();

  /**
   * Check if action allowed
   */
  public isAllowed(actionId: string, minInterval: number): boolean {
    const now = Date.now();
    const lastTime = this.lastClick.get(actionId) || 0;

    if (now - lastTime < minInterval) {
      return false;
    }

    this.lastClick.set(actionId, now);
    return true;
  }

  /**
   * Get remaining time
   */
  public getRemainingTime(actionId: string, minInterval: number): number {
    const now = Date.now();
    const lastTime = this.lastClick.get(actionId) || 0;
    return Math.max(0, minInterval - (now - lastTime));
  }
}

/**
 * Rate-limited button component
 */
class RateLimitedButton {
  private button: HTMLButtonElement;
  private limiter = new ButtonRateLimiter();
  private actionId: string;
  private minInterval: number;
  private originalText: string;

  constructor(
    button: HTMLButtonElement,
    minInterval = 3000 // 3 seconds
  ) {
    this.button = button;
    this.actionId = button.id || `btn_${Date.now()}`;
    this.minInterval = minInterval;
    this.originalText = button.textContent || 'Submit';

    this.setupListener();
  }

  private setupListener(): void {
    this.button.addEventListener('click', (event) => {
      if (!this.limiter.isAllowed(this.actionId, this.minInterval)) {
        event.preventDefault();
        event.stopPropagation();

        const remaining = Math.ceil(
          this.limiter.getRemainingTime(this.actionId, this.minInterval) / 1000
        );

        this.showCooldown(remaining);
        return;
      }

      this.disable();
    });
  }

  private disable(): void {
    this.button.disabled = true;
    this.button.textContent = 'Processing...';

    // Auto-enable after interval
    setTimeout(() => {
      this.button.disabled = false;
      this.button.textContent = this.originalText;
    }, this.minInterval);
  }

  private showCooldown(seconds: number): void {
    this.button.textContent = `Wait ${seconds}s`;
    
    setTimeout(() => {
      const remaining = this.limiter.getRemainingTime(this.actionId, this.minInterval);
      if (remaining > 0) {
        this.showCooldown(Math.ceil(remaining / 1000));
      } else {
        this.button.textContent = this.originalText;
      }
    }, 1000);
  }
}

/**
 * Action-specific rate limiter
 */
interface RateLimitConfig {
  maxAttempts: number;
  windowMs: number;
  blockDurationMs?: number;
}

class ActionRateLimiter {
  private attempts: Map<string, number[]> = new Map();
  private blocked: Map<string, number> = new Map();

  /**
   * Check if action is allowed
   */
  public isAllowed(actionId: string, config: RateLimitConfig): boolean {
    const now = Date.now();

    // Check if blocked
    const blockedUntil = this.blocked.get(actionId);
    if (blockedUntil && now < blockedUntil) {
      return false;
    }

    // Get recent attempts
    const recentAttempts = this.getRecentAttempts(actionId, config.windowMs);

    // Check limit
    if (recentAttempts.length >= config.maxAttempts) {
      // Block for duration
      if (config.blockDurationMs) {
        this.blocked.set(actionId, now + config.blockDurationMs);
      }
      return false;
    }

    // Record attempt
    this.recordAttempt(actionId);
    return true;
  }

  private getRecentAttempts(actionId: string, windowMs: number): number[] {
    const now = Date.now();
    const attempts = this.attempts.get(actionId) || [];
    
    // Filter attempts within window
    const recent = attempts.filter((time) => now - time < windowMs);
    this.attempts.set(actionId, recent);
    
    return recent;
  }

  private recordAttempt(actionId: string): void {
    const attempts = this.attempts.get(actionId) || [];
    attempts.push(Date.now());
    this.attempts.set(actionId, attempts);
  }

  public getRemainingAttempts(actionId: string, config: RateLimitConfig): number {
    const recent = this.getRecentAttempts(actionId, config.windowMs);
    return Math.max(0, config.maxAttempts - recent.length);
  }

  public getBlockedUntil(actionId: string): number | null {
    return this.blocked.get(actionId) || null;
  }
}

Usage Examples

// Simple button rate limiting
const submitButton = document.getElementById('submit') as HTMLButtonElement;
new RateLimitedButton(submitButton, 3000); // 3 second cooldown

// Action rate limiting
const actionLimiter = new ActionRateLimiter();

async function purchaseItem(itemId: string): Promise<void> {
  const allowed = actionLimiter.isAllowed('purchase', {
    maxAttempts: 3,
    windowMs: 60 * 1000, // 3 attempts per minute
    blockDurationMs: 5 * 60 * 1000, // Block for 5 minutes after limit
  });

  if (!allowed) {
    alert('Too many attempts. Please wait.');
    return;
  }

  // Proceed with purchase
  await fetch('/api/purchase', {
    method: 'POST',
    body: JSON.stringify({ itemId }),
  });
}

React Component

import { useState, useCallback } from 'react';

function useRateLimit(minInterval: number) {
  const [canSubmit, setCanSubmit] = useState(true);
  const [remaining, setRemaining] = useState(0);

  const submit = useCallback(async (action: () => Promise<void>) => {
    if (!canSubmit) return;

    setCanSubmit(false);
    let timeLeft = minInterval / 1000;
    setRemaining(timeLeft);

    // Countdown
    const interval = setInterval(() => {
      timeLeft--;
      setRemaining(timeLeft);
      if (timeLeft <= 0) {
        clearInterval(interval);
        setCanSubmit(true);
      }
    }, 1000);

    try {
      await action();
    } catch (error) {
      console.error(error);
    }
  }, [canSubmit, minInterval]);

  return { submit, canSubmit, remaining };
}

// Usage
function PurchaseButton() {
  const { submit, canSubmit, remaining } = useRateLimit(5000);

  const handlePurchase = async () => {
    await submit(async () => {
      await fetch('/api/purchase', { method: 'POST' });
    });
  };

  return (
    <button onClick={handlePurchase} disabled={!canSubmit}>
      {canSubmit ? 'Purchase' : `Wait ${remaining}s`}
    </button>
  );
}

Best Practices

  1. Critical actions only: Purchase, delete, transfer
  2. Server-side validation: Client is not enough
  3. Clear feedback: Show countdown timer
  4. Disable button: Prevent clicks during cooldown
  5. Reasonable limits: Don't frustrate users
  6. Progressive blocking: Increase delay after repeated attempts

Client-side rate limiting is a UX enhancement, not a security measure. Always validate server-side.

On this page