PatternsSecurity Patterns
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
- Critical actions only: Purchase, delete, transfer
- Server-side validation: Client is not enough
- Clear feedback: Show countdown timer
- Disable button: Prevent clicks during cooldown
- Reasonable limits: Don't frustrate users
- Progressive blocking: Increase delay after repeated attempts
Client-side rate limiting is a UX enhancement, not a security measure. Always validate server-side.