Pada aplikasi React Native skala produksi, inisialisasi awal (cold start) atau navigasi ke layar yang kompleks sering memicu render bersamaan dari beberapa komponen independen. Ketika beberapa komponen terisolasi—seperti header profil, lencana notifikasi, dan kartu pengaturan—membutuhkan data identik dari endpoint yang sama saat status cache lokal masih kosong, fenomena cache stampede atau thundering herd terjadi.

Masalah ini menyebabkan transmisi request jaringan yang redundan, lonjakan konkurensi di backend, dan pemborosan daya baterai seluler. Solusi yang tepat untuk skenario ini adalah menerapkan pola Single-Flight (in-flight request coalescing) langsung pada layer HTTP client.

Anatomi Masalah: Thundering Herd pada Cold Start React Native

Saat aplikasi me-mount pohon komponen, inisialisasi state berjalan secara konkuren di JavaScript thread. Jika lima komponen mengeksekusi fetch('/api/v1/profile') pada saat yang hampir bersamaan, React Native menjadwalkan lima tugas jaringan paralel melalui bridge native networking (seperti NSURLSession di iOS atau OkHttp di Android).

Dampak teknis dari fenomena ini meliputi:

  • Redundansi Payload: Mengunduh byte JSON yang sama berulang kali menghabiskan alokasi bandwidth seluler pengguna secara sia-sia.
  • Beban Ekstrem pada Backend: Database dan server API harus mengurai, mengotentikasi, dan memproses kueri duplikat dalam jendela waktu milidetik yang sama.
  • Konsumsi Daya Baterai: Modem seluler (LTE/5G) dipaksa bertahan lebih lama pada high-power radio state akibat serangkaian transmisi paket data paralel yang tidak perlu.

Arsitektur Single-Flight: In-Flight Request Coalescing

Pola Single-Flight menahan eksekusi fetch sekunder jika fetch dengan kunci identik sedang berjalan (in-flight). Berbeda dari caching data konvensional (seperti HTTP cache atau React Query stale-time) yang menyimpan respons data secara persisten, Single-Flight murni mengelola siklus hidup Promise.

Mekanismenya sederhana namun kritis:

  1. Permintaan masuk diverifikasi terhadap registry in-flight aktif berbasis Map<string, Promise<T>>.
  2. Jika kunci ditemukan, fungsi langsung mengembalikan Promise yang sama tanpa mengeksekusi I/O jaringan.
  3. Jika kunci tidak ditemukan, instansiasi fungsi eksekusi jaringan dibungkus dalam Promise baru dan disimpan ke dalam registry.
  4. Ketika Promise berstatus settled (baik fulfilled maupun rejected), entri kunci harus segera dieviction dari registry untuk mencegah memory leak dan memastikan pemanggilan berikutnya mendapatkan data segar.

Implementasi Modul Single-Flight dengan TypeScript

Berikut adalah implementasi modul Single-Flight yang menjamin isolasi error, penghapusan referensi memori pasca-settle, dan penanganan tipe yang ketat.

// single-flight.ts
export class SingleFlightGroup {
  private inFlight = new Map<string, Promise<unknown>>();

  /**
   * Menjalankan fungsi async dengan deduplikasi request aktif.
   * ponytail: plain Map tanpa TTL eviction; in-flight menjamin pembersihan instan pasca-settle.
   */
  async do<T>(key: string, fn: () => Promise<T>): Promise<T> {
    const existingPromise = this.inFlight.get(key);
    if (existingPromise) {
      return existingPromise as Promise<T>;
    }

    // Bungkus eksekusi untuk mengisolasi penanganan siklus hidup
    const promise = (async () => {
      try {
        return await fn();
      } finally {
        // Eviksi registry segera setelah promise selesai (resolve/reject)
        this.inFlight.delete(key);
      }
    })();

    this.inFlight.set(key, promise);
    return promise;
  }

  // Helper introspeksi untuk verifikasi dan debugging
  get activeCount(): number {
    return this.inFlight.size;
  }
}
Catatan Isolasi Error: Blok finally memastikan registry dibersihkan baik saat request sukses maupun saat server merespons dengan error (HTTP 500 atau timeout). Jika request gagal, pemanggilan di detik berikutnya tidak akan tertahan oleh rejected promise sebelumnya.

Integrasi pada Layer HTTP Client

Gunakan kelas ini sebagai wrapper pemanggilan API atau gabungkan langsung ke dalam interceptor/klien fetch dasar aplikasi Anda:

// apiClient.ts
import { SingleFlightGroup } from './single-flight';

const flightGroup = new SingleFlightGroup();

interface RequestOptions extends RequestInit {
  idempotentKey?: string;
}

export async function deduplicatedFetch<T>(
  url: string,
  options: RequestOptions = {}
): Promise<T> {
  // Hanya deduplikasi method GET atau request eksplisit
  const method = options.method?.toUpperCase() || 'GET';
  if (method !== 'GET') {
    const response = await fetch(url, options);
    if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
    return response.json();
  }

  const cacheKey = options.idempotentKey || `${method}:${url}`;

  return flightGroup.do<T>(cacheKey, async () => {
    const response = await fetch(url, options);
    if (!response.ok) {
      throw new Error(`HTTP Error ${response.status}`);
    }
    return response.json();
  });
}

Verifikasi Teknis: Runnable Assert Check

Kode uji mandiri di bawah ini membuktikan bahwa lima pemanggilan paralel ke endpoint identik hanya memicu satu eksekusi jaringan riil. Simpan dan jalankan menggunakan Node.js (node --loader ts-node test.ts atau transpile ke JS).

// single-flight.test.ts
import assert from 'node:assert/strict';
import { SingleFlightGroup } from './single-flight';

async function runTest() {
  const flight = new SingleFlightGroup();
  let networkCallCount = 0;

  // Mock fungsi I/O jaringan dengan delay 50ms
  const mockFetchUserProfile = async (userId: string) => {
    networkCallCount++;
    await new Promise((resolve) => setTimeout(resolve, 50));
    return { id: userId, name: 'Senior Dev' };
  };

  // Jalankan 5 pemanggilan fungsi secara paralel saat cold start
  const promises = [
    flight.do('user:101', () => mockFetchUserProfile('101')),
    flight.do('user:101', () => mockFetchUserProfile('101')),
    flight.do('user:101', () => mockFetchUserProfile('101')),
    flight.do('user:101', () => mockFetchUserProfile('101')),
    flight.do('user:101', () => mockFetchUserProfile('101')),
  ];

  // Pastikan in-flight map hanya menampung 1 promise aktif
  assert.equal(flight.activeCount, 1, 'Harus hanya ada 1 request aktif di dalam registry');

  const results = await Promise.all(promises);

  // Verifikasi hasil data
  assert.equal(networkCallCount, 1, 'Network call aktual harus dieksekusi tepat 1 kali');
  assert.equal(results.length, 5, 'Harus mengembalikan 5 hasil data');
  for (const res of results) {
    assert.deepEqual(res, { id: '101', name: 'Senior Dev' });
  }

  // Verifikasi cleanup memori pasca-settle
  assert.equal(flight.activeCount, 0, 'Registry in-flight harus kosong setelah promise settle');

  // Verifikasi pemanggilan lanjutan setelah settle memicu fetch baru (bukan memory leak atau perma-cache)
  await flight.do('user:101', () => mockFetchUserProfile('101'));
  assert.equal(networkCallCount, 2, 'Request baru harus memicu network call baru setelah siklus selesai');

  console.log('Semua assert lolos: Deduplikasi Single-Flight berfungsi presisi.');
}

runTest().catch((err) => {
  console.error('Test gagal:', err);
  process.exit(1);
});

Trade-offs dan Pertimbangan Edge Cases

  • Scope Mutasi (Non-Idempotent Methods): Jangan pernah mendeduplikasi request POST, PUT, atau PATCH secara default tanpa kunci idempotensi eksplisit, karena operasi mutasi membawa side-effects yang valid dan berbeda.
  • AbortController Propagation: Jika satu komponen meng-unmount dan memicu AbortController.abort(), pastikan pembatalan tersebut tidak membatalkan Promise bersama yang sedang ditunggu oleh empat komponen lain yang masih aktif. Single-Flight sebaiknya mengeksekusi fetch terpisah dari sinyal abort individual komponen.
  • Key Normalization: Serialisasi kunci harus konsisten. Perbedaan urutan query parameter (misal ?page=1&limit=10 vs ?limit=10&page=1) akan dianggap sebagai dua entri berbeda kecuali URL dinormalisasi terlebih dahulu.