Skip to content

Request Batching & Debouncing Strategies

Batching and debouncing reduce mobile network chatter, but they should not hide slow backend behavior or delay critical user actions.

Quick Decision

SituationApproachWatch Out
User creates rapid inputDebounceToo much delay is felt
Many tiny requests existBatchError splitting is needed
Same data is requested repeatedlyRequest coalescingCache key must be correct
Critical immediate actionDo not debounceUser trust suffers

Production Checklist

  • Problem: Are there truly too many network calls, or is the backend slow?
  • Solution: Are batch window, max size, cancellation, partial failure, and retry clear?
  • Trade-off: Fewer requests reduce battery and cost; latency and error handling get more complex.
  • Failure mode: Lost requests, duplicate sends, partial batch failures, and stale results should be handled.
  • Measurement: Track request count, batch size, debounce delay, cancellation rate, and p95 latency.
  • Security/cost: Requests from different users/tenants must not be mixed in the same batch.

Request Batching

Basic Batching Implementation

Request batching is a critical technique for reducing network overhead and improving mobile app performance by combining multiple small requests into fewer larger ones.

Simple Request Batcher

javascript
class RequestBatcher {
  constructor(options = {}) {
    this.batchSize = options.batchSize || 10;
    this.batchTimeout = options.batchTimeout || 100; // ms
    this.pendingRequests = [];
    this.timer = null;
  }

  addRequest(request) {
    return new Promise((resolve, reject) => {
      this.pendingRequests.push({
        ...request,
        resolve,
        reject
      });

      if (this.pendingRequests.length >= this.batchSize) {
        this.flush();
      } else if (!this.timer) {
        this.timer = setTimeout(() => this.flush(), this.batchTimeout);
      }
    });
  }

  flush() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }

    if (this.pendingRequests.length === 0) return;

    const requestsToProcess = this.pendingRequests.slice();
    this.pendingRequests = [];

    this.processBatch(requestsToProcess);
  }

  async processBatch(requests) {
    try {
      const batchPayload = {
        requests: requests.map(req => ({
          id: req.id || this.generateId(),
          method: req.method,
          url: req.url,
          data: req.data,
          headers: req.headers
        }))
      };

      const response = await fetch('/api/batch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(batchPayload)
      });

      const batchResult = await response.json();
      
      // Distribute results to individual requests
      requests.forEach(request => {
        const result = batchResult.responses.find(r => r.id === request.id);
        if (result) {
          if (result.success) {
            request.resolve(result.data);
          } else {
            request.reject(new Error(result.error));
          }
        } else {
          request.reject(new Error('No response found for request'));
        }
      });
    } catch (error) {
      // If batch fails, reject all requests
      requests.forEach(request => request.reject(error));
    }
  }

  generateId() {
    return Math.random().toString(36).substring(2) + Date.now().toString(36);
  }
}

// Usage
const batcher = new RequestBatcher({ batchSize: 5, batchTimeout: 200 });

// Individual requests are automatically batched
batcher.addRequest({
  method: 'GET',
  url: '/api/users/1'
}).then(user => console.log(user));

batcher.addRequest({
  method: 'GET',
  url: '/api/users/2'
}).then(user => console.log(user));

Smart Batching with Request Types

javascript
class SmartRequestBatcher {
  constructor() {
    this.batchers = new Map();
    this.defaultConfig = {
      read: { batchSize: 20, timeout: 50 },
      write: { batchSize: 5, timeout: 100 },
      delete: { batchSize: 10, timeout: 200 }
    };
  }

  addRequest(request) {
    const requestType = this.getRequestType(request);
    
    if (!this.batchers.has(requestType)) {
      const config = this.defaultConfig[requestType] || this.defaultConfig.read;
      this.batchers.set(requestType, new RequestBatcher(config));
    }

    return this.batchers.get(requestType).addRequest(request);
  }

  getRequestType(request) {
    if (request.method === 'GET') return 'read';
    if (request.method === 'POST' || request.method === 'PUT') return 'write';
    if (request.method === 'DELETE') return 'delete';
    return 'read';
  }

  // Priority-based batching
  addPriorityRequest(request, priority = 'normal') {
    if (priority === 'high') {
      // High priority requests bypass batching
      return this.executeImmediately(request);
    }
    
    return this.addRequest(request);
  }

  async executeImmediately(request) {
    try {
      const response = await fetch(request.url, {
        method: request.method,
        headers: request.headers,
        body: request.data ? JSON.stringify(request.data) : null
      });
      
      return await response.json();
    } catch (error) {
      throw error;
    }
  }
}

GraphQL Batching

javascript
class GraphQLBatcher {
  constructor(endpoint, options = {}) {
    this.endpoint = endpoint;
    this.batchTimeout = options.batchTimeout || 10;
    this.maxBatchSize = options.maxBatchSize || 10;
    this.pendingQueries = [];
    this.batchTimer = null;
  }

  query(query, variables = {}) {
    return new Promise((resolve, reject) => {
      const queryRequest = {
        query: query.loc ? query.loc.source.body : query,
        variables,
        resolve,
        reject
      };

      this.pendingQueries.push(queryRequest);

      if (this.pendingQueries.length >= this.maxBatchSize) {
        this.executeBatch();
      } else if (!this.batchTimer) {
        this.batchTimer = setTimeout(() => this.executeBatch(), this.batchTimeout);
      }
    });
  }

  async executeBatch() {
    if (this.batchTimer) {
      clearTimeout(this.batchTimer);
      this.batchTimer = null;
    }

    if (this.pendingQueries.length === 0) return;

    const currentBatch = this.pendingQueries.slice();
    this.pendingQueries = [];

    try {
      const batchedQuery = this.createBatchedQuery(currentBatch);
      
      const response = await fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(batchedQuery)
      });

      const results = await response.json();
      
      // Handle batch response
      if (Array.isArray(results)) {
        results.forEach((result, index) => {
          const originalQuery = currentBatch[index];
          if (result.errors) {
            originalQuery.reject(new Error(result.errors[0].message));
          } else {
            originalQuery.resolve(result.data);
          }
        });
      } else {
        // Single response for batch
        currentBatch.forEach(query => {
          if (results.errors) {
            query.reject(new Error(results.errors[0].message));
          } else {
            query.resolve(results.data);
          }
        });
      }
    } catch (error) {
      currentBatch.forEach(query => query.reject(error));
    }
  }

  createBatchedQuery(queries) {
    if (queries.length === 1) {
      return {
        query: queries[0].query,
        variables: queries[0].variables
      };
    }

    // Create a single query with multiple operations
    const operations = queries.map((q, index) => {
      const operationName = `batch_${index}`;
      return q.query.replace('query', `query ${operationName}`);
    });

    return {
      query: operations.join('\n'),
      variables: queries.reduce((acc, q, index) => {
        Object.keys(q.variables).forEach(key => {
          acc[`${key}_${index}`] = q.variables[key];
        });
        return acc;
      }, {})
    };
  }
}

Debouncing Mechanisms

Input Debouncing

javascript
class InputDebouncer {
  constructor(delay = 300) {
    this.delay = delay;
    this.timers = new Map();
  }

  debounce(key, callback, customDelay) {
    const delay = customDelay || this.delay;
    
    // Clear existing timer for this key
    if (this.timers.has(key)) {
      clearTimeout(this.timers.get(key));
    }

    // Set new timer
    const timer = setTimeout(() => {
      callback();
      this.timers.delete(key);
    }, delay);

    this.timers.set(key, timer);
  }

  // Immediate execution with trailing debounce
  debounceLeading(key, callback, customDelay) {
    const delay = customDelay || this.delay;
    
    if (!this.timers.has(key)) {
      // Execute immediately on first call
      callback();
      
      // Set timer to prevent subsequent calls
      const timer = setTimeout(() => {
        this.timers.delete(key);
      }, delay);
      
      this.timers.set(key, timer);
    }
  }

  // Cancel specific debounced function
  cancel(key) {
    if (this.timers.has(key)) {
      clearTimeout(this.timers.get(key));
      this.timers.delete(key);
    }
  }

  // Cancel all debounced functions
  cancelAll() {
    this.timers.forEach(timer => clearTimeout(timer));
    this.timers.clear();
  }
}

// Search input debouncing example
class SearchDebouncer {
  constructor(searchFunction) {
    this.debouncer = new InputDebouncer(300);
    this.searchFunction = searchFunction;
    this.lastQuery = '';
  }

  search(query) {
    if (query === this.lastQuery) return;
    
    this.lastQuery = query;
    
    if (query.trim() === '') {
      this.debouncer.cancel('search');
      this.clearResults();
      return;
    }

    this.debouncer.debounce('search', () => {
      this.executeSearch(query);
    });
  }

  async executeSearch(query) {
    try {
      const results = await this.searchFunction(query);
      this.displayResults(results);
    } catch (error) {
      console.error('Search failed:', error);
      this.displayError(error);
    }
  }

  displayResults(results) {
    // Update UI with search results
    console.log('Search results:', results);
  }

  displayError(error) {
    // Show error message
    console.error('Search error:', error);
  }

  clearResults() {
    // Clear search results
    console.log('Clearing search results');
  }
}

API Call Debouncing

javascript
class APIDebouncer {
  constructor() {
    this.pendingCalls = new Map();
    this.debounceDelay = 250;
  }

  async debouncedCall(endpoint, options = {}, customDelay) {
    const delay = customDelay || this.debounceDelay;
    const callKey = this.generateCallKey(endpoint, options);

    // Cancel existing call
    if (this.pendingCalls.has(callKey)) {
      this.pendingCalls.get(callKey).cancel();
    }

    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(async () => {
        try {
          const result = await this.makeCall(endpoint, options);
          this.pendingCalls.delete(callKey);
          resolve(result);
        } catch (error) {
          this.pendingCalls.delete(callKey);
          reject(error);
        }
      }, delay);

      this.pendingCalls.set(callKey, {
        timeoutId,
        cancel: () => {
          clearTimeout(timeoutId);
          this.pendingCalls.delete(callKey);
          reject(new Error('Debounced call cancelled'));
        }
      });
    });
  }

  generateCallKey(endpoint, options) {
    // Create unique key based on endpoint and relevant options
    const keyData = {
      endpoint,
      method: options.method || 'GET',
      params: options.params || {},
      // Don't include things like timestamps in key
    };
    
    return JSON.stringify(keyData);
  }

  async makeCall(endpoint, options) {
    const response = await fetch(endpoint, {
      method: options.method || 'GET',
      headers: options.headers || {},
      body: options.body ? JSON.stringify(options.body) : null
    });

    if (!response.ok) {
      throw new Error(`API call failed: ${response.statusText}`);
    }

    return await response.json();
  }

  // Throttling variant - limits call frequency
  async throttledCall(endpoint, options = {}, throttleMs = 1000) {
    const callKey = this.generateCallKey(endpoint, options);
    const now = Date.now();
    
    const lastCall = this.lastCallTimes?.get(callKey) || 0;
    
    if (now - lastCall < throttleMs) {
      // Return cached result or throw error
      throw new Error('Call throttled');
    }

    if (!this.lastCallTimes) {
      this.lastCallTimes = new Map();
    }
    
    this.lastCallTimes.set(callKey, now);
    
    return await this.makeCall(endpoint, options);
  }
}

// Usage example
const apiDebouncer = new APIDebouncer();

// User types in search box
document.getElementById('search').addEventListener('input', async (e) => {
  const query = e.target.value;
  
  if (query.length < 2) return;
  
  try {
    const results = await apiDebouncer.debouncedCall('/api/search', {
      method: 'GET',
      params: { q: query }
    });
    
    displaySearchResults(results);
  } catch (error) {
    if (error.message !== 'Debounced call cancelled') {
      console.error('Search failed:', error);
    }
  }
});

Smart Optimization

Adaptive Batching

javascript
class AdaptiveBatcher {
  constructor() {
    this.metrics = {
      batchSizes: [],
      responseTimes: [],
      successRates: []
    };
    this.currentConfig = {
      batchSize: 10,
      timeout: 100
    };
    this.learningEnabled = true;
  }

  async addRequest(request) {
    const startTime = Date.now();
    
    try {
      const result = await this.batcher.addRequest(request);
      
      if (this.learningEnabled) {
        this.recordSuccess(Date.now() - startTime);
      }
      
      return result;
    } catch (error) {
      if (this.learningEnabled) {
        this.recordFailure(Date.now() - startTime);
      }
      throw error;
    }
  }

  recordSuccess(responseTime) {
    this.metrics.responseTimes.push(responseTime);
    this.metrics.successRates.push(1);
    this.metrics.batchSizes.push(this.currentConfig.batchSize);
    
    this.adaptConfiguration();
  }

  recordFailure(responseTime) {
    this.metrics.responseTimes.push(responseTime);
    this.metrics.successRates.push(0);
    this.metrics.batchSizes.push(this.currentConfig.batchSize);
    
    this.adaptConfiguration();
  }

  adaptConfiguration() {
    // Only adapt after collecting enough data
    if (this.metrics.responseTimes.length < 10) return;

    const recent = this.getRecentMetrics(20);
    const avgResponseTime = this.average(recent.responseTimes);
    const successRate = this.average(recent.successRates);
    
    // If performance is poor, reduce batch size
    if (avgResponseTime > 2000 || successRate < 0.9) {
      this.currentConfig.batchSize = Math.max(1, this.currentConfig.batchSize - 1);
      this.currentConfig.timeout = Math.min(1000, this.currentConfig.timeout + 50);
    }
    // If performance is good, try to increase efficiency
    else if (avgResponseTime < 500 && successRate > 0.95) {
      this.currentConfig.batchSize = Math.min(50, this.currentConfig.batchSize + 1);
      this.currentConfig.timeout = Math.max(50, this.currentConfig.timeout - 10);
    }

    // Update batcher configuration
    this.updateBatcher();
  }

  getRecentMetrics(count) {
    const sliceStart = Math.max(0, this.metrics.responseTimes.length - count);
    
    return {
      responseTimes: this.metrics.responseTimes.slice(sliceStart),
      successRates: this.metrics.successRates.slice(sliceStart),
      batchSizes: this.metrics.batchSizes.slice(sliceStart)
    };
  }

  average(arr) {
    return arr.reduce((sum, val) => sum + val, 0) / arr.length;
  }

  updateBatcher() {
    this.batcher = new RequestBatcher({
      batchSize: this.currentConfig.batchSize,
      batchTimeout: this.currentConfig.timeout
    });
  }
}

Network-Aware Optimization

javascript
class NetworkAwareOptimizer {
  constructor() {
    this.networkInfo = this.getNetworkInfo();
    this.adaptToNetwork();
    
    // Listen for network changes
    if ('connection' in navigator) {
      navigator.connection.addEventListener('change', () => {
        this.networkInfo = this.getNetworkInfo();
        this.adaptToNetwork();
      });
    }
  }

  getNetworkInfo() {
    if ('connection' in navigator) {
      const conn = navigator.connection;
      return {
        effectiveType: conn.effectiveType,
        downlink: conn.downlink,
        rtt: conn.rtt,
        saveData: conn.saveData
      };
    }
    return { effectiveType: '4g' }; // default
  }

  adaptToNetwork() {
    const config = this.getOptimalConfig();
    
    // Update batching configuration
    this.batchConfig = config.batching;
    this.debounceConfig = config.debouncing;
    
    console.log('Network adapted:', {
      network: this.networkInfo.effectiveType,
      config: config
    });
  }

  getOptimalConfig() {
    switch (this.networkInfo.effectiveType) {
      case 'slow-2g':
      case '2g':
        return {
          batching: { batchSize: 3, timeout: 1000 },
          debouncing: { delay: 800 }
        };
      
      case '3g':
        return {
          batching: { batchSize: 5, timeout: 500 },
          debouncing: { delay: 400 }
        };
      
      case '4g':
        return {
          batching: { batchSize: 10, timeout: 200 },
          debouncing: { delay: 200 }
        };
      
      case '5g':
        return {
          batching: { batchSize: 20, timeout: 50 },
          debouncing: { delay: 100 }
        };
      
      default:
        return {
          batching: { batchSize: 10, timeout: 200 },
          debouncing: { delay: 300 }
        };
    }
  }

  // Create optimized batcher based on network
  createOptimizedBatcher() {
    return new RequestBatcher(this.batchConfig);
  }

  // Create optimized debouncer based on network
  createOptimizedDebouncer() {
    return new InputDebouncer(this.debounceConfig.delay);
  }
}

Platform-Specific Optimizations

Android Optimizations

kotlin
class AndroidBatchingOptimizer(private val context: Context) {
    private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    private val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
    
    fun getOptimalBatchConfiguration(): BatchConfiguration {
        val networkInfo = connectivityManager.activeNetworkInfo
        val isLowPowerMode = powerManager.isPowerSaveMode
        val isMeteredConnection = connectivityManager.isActiveNetworkMetered
        
        return when {
            isLowPowerMode -> {
                // Conservative batching to save battery
                BatchConfiguration(
                    batchSize = 20,
                    timeout = 2000,
                    priority = "battery"
                )
            }
            isMeteredConnection -> {
                // Aggressive batching to save data
                BatchConfiguration(
                    batchSize = 15,
                    timeout = 1000,
                    priority = "data"
                )
            }
            networkInfo?.type == ConnectivityManager.TYPE_WIFI -> {
                // Optimal performance on WiFi
                BatchConfiguration(
                    batchSize = 5,
                    timeout = 100,
                    priority = "performance"
                )
            }
            else -> {
                // Default mobile configuration
                BatchConfiguration(
                    batchSize = 10,
                    timeout = 300,
                    priority = "balanced"
                )
            }
        }
    }
    
    fun scheduleOptimalBatching() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()
            
        val batchingWork = OneTimeWorkRequestBuilder<BatchingWorker>()
            .setConstraints(constraints)
            .build()
            
        WorkManager.getInstance(context).enqueue(batchingWork)
    }
}

data class BatchConfiguration(
    val batchSize: Int,
    val timeout: Long,
    val priority: String
)

iOS Optimizations

swift
class iOSBatchingOptimizer {
    private let reachability = SCNetworkReachability.forInternet()
    
    func getOptimalBatchConfiguration() -> BatchConfiguration {
        let networkStatus = getCurrentNetworkStatus()
        let batteryLevel = UIDevice.current.batteryLevel
        let isLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled
        
        switch (networkStatus, isLowPowerMode) {
        case (.wifi, false):
            return BatchConfiguration(
                batchSize: 5,
                timeout: 0.1,
                priority: .performance
            )
            
        case (.cellular, _) where batteryLevel < 0.2:
            return BatchConfiguration(
                batchSize: 20,
                timeout: 2.0,
                priority: .battery
            )
            
        case (.cellular, true):
            return BatchConfiguration(
                batchSize: 15,
                timeout: 1.0,
                priority: .efficiency
            )
            
        default:
            return BatchConfiguration(
                batchSize: 10,
                timeout: 0.3,
                priority: .balanced
            )
        }
    }
    
    private func getCurrentNetworkStatus() -> NetworkStatus {
        // Implementation to detect current network
        // Using Network framework or Reachability
        return .wifi // placeholder
    }
}

struct BatchConfiguration {
    let batchSize: Int
    let timeout: TimeInterval
    let priority: Priority
    
    enum Priority {
        case performance, battery, efficiency, balanced
    }
}

enum NetworkStatus {
    case wifi, cellular, none
}

These batching and debouncing strategies provide robust foundation for efficient mobile networking, ensuring optimal performance across different network conditions and device capabilities.

Created by Eren Demir.