Resources / Blog / System Design Notes

System Design Principles for Frontend Engineers

Essential system design concepts tailored for frontend engineers, covering scalability, performance, and architectural patterns.

Jan 25, 202611 min readSevyDevy Team
System DesignArchitectureScalabilityPerformanceFrontendMicroservicesAPI Design

Why Frontend Engineers Need System Design

Modern frontend engineering extends far beyond UI implementation. Frontend engineers must understand how their applications interact with backend systems, handle scale, maintain performance, and ensure reliability. System design thinking enables frontend engineers to make architectural decisions that impact user experience, development velocity, and business outcomes.

Core System Design Principles

  • Separation of Concerns: Isolate business logic, presentation, and data management
  • Single Responsibility: Each component/module should have one clear purpose
  • Loose Coupling: Minimize dependencies between system parts
  • High Cohesion: Related functionality should be grouped together
  • Abstraction: Hide complex implementation details behind simple interfaces
  • Composition over Inheritance: Build complex systems from simple, reusable parts

Frontend Architecture Patterns

Different architectural patterns solve different problems. Choosing the right pattern depends on your application's scale, team structure, and requirements.

// Feature-based architecture
src/
├── features/
│   ├── authentication/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── utils/
│   │   └── index.ts
│   ├── dashboard/
│   └── checkout/
├── shared/
│   ├── components/
│   ├── hooks/
│   └── utils/
└── lib/
    ├── api/
    └── config/

// Layered architecture
export function useProductService() {
  // Service layer
  const api = useApi();
  
  return {
    // Business logic layer
    async getProducts() {
      const data = await api.get('/products');
      return transformProducts(data); // Data transformation layer
    },
    
    async updateProduct(id, updates) {
      validateProduct(updates); // Validation layer
      return api.put(`/products/${id}`, updates);
    }
  };
}

// Hexagonal architecture (Ports and Adapters)
interface ProductRepository {
  getById(id: string): Promise<Product>;
  save(product: Product): Promise<void>;
}

class APIProuductRepository implements ProductRepository {
  async getById(id: string): Promise<Product> {
    const response = await fetch(`/api/products/${id}`);
    return response.json();
  }
  
  async save(product: Product): Promise<void> {
    await fetch('/api/products', {
      method: 'POST',
      body: JSON.stringify(product),
    });
  }
}

// Use case
class GetProductUseCase {
  constructor(private repository: ProductRepository) {}
  
  async execute(id: string): Promise<Product> {
    return this.repository.getById(id);
  }
}

State Management at Scale

As applications grow, state management becomes critical. Different state types require different management strategies.

// State categorization
type AppState = {
  // UI State: Component-specific, ephemeral
  ui: {
    modals: ModalState[];
    notifications: Notification[];
    theme: 'light' | 'dark';
  };
  
  // Client State: User-specific, persisted locally
  client: {
    preferences: UserPreferences;
    recentSearches: string[];
    cart: CartItem[];
  };
  
  // Server State: Shared, cached, synchronized
  server: {
    products: Record<string, Product>;
    user: User | null;
    orders: Order[];
  };
  
  // Derived State: Computed from other states
  derived: {
    cartTotal: number;
    filteredProducts: Product[];
    userInitials: string;
  };
};

// State management strategy matrix
const stateStrategies = {
  uiState: {
    tool: 'React useState/useReducer',
    persistence: 'Memory',
    synchronization: 'None',
    example: 'Form inputs, modal visibility'
  },
  clientState: {
    tool: 'Zustand/Context + localStorage',
    persistence: 'Local storage',
    synchronization: 'None',
    example: 'User preferences, shopping cart'
  },
  serverState: {
    tool: 'React Query/SWR/RTK Query',
    persistence: 'Memory + Cache',
    synchronization: 'Polling/WebSocket',
    example: 'Product catalog, user profile'
  },
  derivedState: {
    tool: 'useMemo/useSelector',
    persistence: 'Memory (computed)',
    synchronization: 'Reactive',
    example: 'Search results, totals'
  }
};

// Global state with Zustand
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AppStore {
  theme: 'light' | 'dark';
  user: User | null;
  setTheme: (theme: 'light' | 'dark') => void;
  setUser: (user: User | null) => void;
}

export const useAppStore = create<AppStore>()(
  persist(
    (set) => ({
      theme: 'light',
      user: null,
      setTheme: (theme) => set({ theme }),
      setUser: (user) => set({ user }),
    }),
    {
      name: 'app-storage',
      partialize: (state) => ({ theme: state.theme }), // Only persist theme
    }
  )
);

API Communication Patterns

Efficient API communication is crucial for performance and user experience. Different patterns solve different challenges.

// API Client abstraction
class ApiClient {
  private baseURL: string;
  private defaultHeaders: Record<string, string>;

  constructor(config: ApiConfig) {
    this.baseURL = config.baseURL;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...config.headers,
    };
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;
    const headers = { ...this.defaultHeaders, ...options.headers };

    const response = await fetch(url, { ...options, headers });

    if (!response.ok) {
      throw new ApiError(response.status, await response.text());
    }

    return response.json();
  }

  // Specific methods
  get<T>(endpoint: string, query?: Record<string, string>): Promise<T> {
    const url = query ? `${endpoint}?${new URLSearchParams(query)}` : endpoint;
    return this.request(url);
  }

  post<T>(endpoint: string, data: unknown): Promise<T> {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
}

// Request deduplication
const pendingRequests = new Map<string, Promise<any>>();

async function deduplicatedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
  if (pendingRequests.has(key)) {
    return pendingRequests.get(key)!;
  }

  const promise = fetcher();
  pendingRequests.set(key, promise);

  try {
    const result = await promise;
    return result;
  } finally {
    pendingRequests.delete(key);
  }
}

// Request queuing for rate-limited APIs
class RequestQueue {
  private queue: Array<() => Promise<any>> = [];
  private processing = false;
  private readonly delay: number;

  constructor(delay: number = 1000) {
    this.delay = delay;
  }

  enqueue<T>(request: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          const result = await request();
          resolve(result);
        } catch (error) {
          reject(error);
        }
      });

      this.process();
    });
  }

  private async process() {
    if (this.processing || this.queue.length === 0) return;

    this.processing = true;
    const request = this.queue.shift()!;

    try {
      await request();
    } finally {
      await new Promise(resolve => setTimeout(resolve, this.delay));
      this.processing = false;
      this.process();
    }
  }
}

// Exponential backoff for retries
async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  maxRetries: number = 3
): Promise<Response> {
  let lastError: Error;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      
      if (response.ok) {
        return response;
      }
      
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      lastError = error as Error;
      
      if (attempt < maxRetries - 1) {
        const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s...
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}

Performance Optimization Patterns

  • Lazy Loading: Load code and data only when needed
  • Prefetching: Load resources before they're requested
  • Caching: Store computed values and API responses
  • Memoization: Cache function results based on inputs
  • Debouncing: Group rapid sequential calls
  • Throttling: Limit call frequency
  • Virtualization: Render only visible content
  • Code Splitting: Divide bundle into smaller chunks

Error Handling and Resilience

Robust systems handle failures gracefully and maintain functionality during partial outages.

// Error boundary for React components
class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ReactNode },
  { hasError: boolean }
> {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Log error to monitoring service
    logError(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

// Circuit breaker pattern
class CircuitBreaker {
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  private failureCount = 0;
  private lastFailureTime = 0;
  private readonly failureThreshold = 5;
  private readonly resetTimeout = 60000; // 60 seconds

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      const timeSinceFailure = Date.now() - this.lastFailureTime;
      
      if (timeSinceFailure > this.resetTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();
      
      if (this.state === 'HALF_OPEN') {
        this.reset();
      }
      
      return result;
    } catch (error) {
      this.recordFailure();
      throw error;
    }
  }

  private recordFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }

  private reset() {
    this.state = 'CLOSED';
    this.failureCount = 0;
  }
}

// Graceful degradation
async function fetchWithFallback(
  primary: () => Promise<any>,
  fallback: () => Promise<any>,
  timeout: number = 5000
): Promise<any> {
  try {
    // Race between primary and timeout
    const result = await Promise.race([
      primary(),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Timeout')), timeout)
      )
    ]);
    
    return result;
  } catch (error) {
    console.warn('Primary source failed, using fallback:', error);
    return fallback();
  }
}

Monitoring and Observability

  • Logging: Structured logs for debugging and audit trails
  • Metrics: Performance indicators and business metrics
  • Tracing: End-to-end request flow tracking
  • Alerting: Automated notifications for issues
  • Dashboards: Real-time system health visualization
  • Error Tracking: Capture and analyze client-side errors
  • Performance Monitoring: Track Core Web Vitals
  • User Analytics: Understand user behavior patterns

Scalability Considerations

  • Horizontal Scaling: Add more instances rather than upgrading single instance
  • CDN Usage: Distribute static assets globally
  • Database Optimization: Indexing, read replicas, connection pooling
  • Caching Strategy: Multi-level caching (memory, CDN, database)
  • Async Processing: Offload heavy operations to background jobs
  • Microservices: Decompose monolith into focused services
  • Message Queues: Decouple services with async communication
  • Stateless Design: Enable easy horizontal scaling

Security Best Practices

  • Input Validation: Validate all user inputs on client and server
  • Output Encoding: Prevent XSS by encoding dynamic content
  • Authentication: Use secure token-based authentication (JWT)
  • Authorization: Implement proper role-based access control
  • HTTPS: Always use encrypted connections
  • CORS: Configure proper cross-origin policies
  • CSRF Protection: Implement anti-CSRF tokens
  • Content Security Policy: Restrict resource loading sources
  • Dependency Scanning: Regularly update and scan dependencies
  • Error Handling: Don't leak sensitive information in errors

Decision Framework

When making architectural decisions, consider these factors:

  • Requirements: Functional and non-functional requirements
  • Constraints: Time, budget, team skills, existing infrastructure
  • Trade-offs: Performance vs complexity, consistency vs availability
  • Evolution: How will the system change over time?
  • Team: Size, structure, and expertise of the development team
  • Maintenance: Long-term maintenance and operational costs
  • Risk: Technical and business risks of each approach