Cache Aside di Supabase cocok saat Supabase/Postgres tetap menjadi source of truth, sementara Redis atau Upstash dipakai untuk mengurangi query berulang yang mahal. Pola ini efektif, tetapi jika diterapkan tanpa TTL, invalidasi, dan proteksi stampede, cache justru bisa memindahkan bottleneck dari database ke lapisan aplikasi.

Artikel ini membahas implementasi praktis cache-aside untuk alur baca di server-side TypeScript, kapan cache diisi, bagaimana memakai TTL, soft TTL, jitter, dan single-flight/locking ringan, serta bagaimana melakukan invalidasi setelah update agar data tidak terlalu lama basi.

Apa itu cache-aside dan kapan dipakai

Pada pola cache-aside, aplikasi membaca dari cache terlebih dahulu. Jika data ada dan masih valid, data langsung dikembalikan. Jika tidak ada atau kedaluwarsa, aplikasi mengambil data dari Supabase/Postgres, lalu menyimpannya ke cache untuk request berikutnya.

Urutannya sederhana:

  1. Request masuk ke aplikasi.
  2. Aplikasi mencoba get ke Redis/Upstash dengan key tertentu.
  3. Jika hit, kembalikan nilai cache.
  4. Jika miss, query ke Supabase.
  5. Simpan hasilnya ke cache dengan TTL.
  6. Kembalikan hasil ke caller.

Pola ini tepat jika:

  • Read jauh lebih sering daripada write.
  • Ada query yang mahal atau sering dipanggil berulang.
  • Data boleh sedikit tertinggal selama ada batas yang jelas.
  • Aplikasi butuh respons stabil saat traffic naik.

Pola ini kurang cocok jika:

  • Data sangat sensitif terhadap freshness, misalnya saldo real-time atau status yang berubah per detik.
  • Cardinality key sangat tinggi tetapi jarang dipakai ulang, sehingga cache hanya menambah biaya memori.
  • Query database sebenarnya murah dan tidak sering dipanggil.

Arsitektur praktis: Supabase sebagai source of truth, Redis sebagai cache

Dalam artikel ini, Supabase/Postgres menjadi sumber data utama. Cache disimpan di Redis atau layanan kompatibel seperti Upstash. Aplikasi server-side memegang logika cache-aside, bukan database.

Contoh alur baca manual

Misalkan ada endpoint untuk mengambil profil publik pengguna berdasarkan userId.

  • Key cache: user:profile:{userId}
  • TTL keras: misalnya 300 detik
  • Soft TTL: misalnya 60 detik

Soft TTL artinya data masih boleh disajikan sesaat setelah batas "segar" terlewati, tetapi aplikasi mulai memicu refresh di belakang layar. Ini membantu menekan lonjakan request ke database ketika banyak request datang bersamaan pada key populer.

Kenapa tidak langsung pakai TTL biasa saja?

TTL biasa memang membatasi umur data, tetapi punya dua masalah umum:

  • Cache stampede: banyak request sekaligus mendapati key habis TTL, lalu semuanya menembak database.
  • Thundering herd: beberapa key populer kedaluwarsa di waktu hampir sama sehingga beban memuncak serentak.

Karena itu, implementasi produksi biasanya menambahkan:

  • TTL keras untuk memastikan cache akhirnya dibuang.
  • Soft TTL untuk refresh lebih dini tanpa memaksa semua request menunggu.
  • Jitter agar waktu kedaluwarsa tersebar, tidak serempak.
  • Single-flight atau lock ringan agar hanya satu request yang melakukan refill saat miss.

Penerapan Cache Aside di Supabase dengan TypeScript

Contoh berikut bersifat server-side. Intinya sama baik Anda memakai Node.js API, server action, edge handler, atau backend worker. Supabase dipakai untuk query Postgres, Redis/Upstash untuk cache.

Format nilai cache

Jangan hanya menyimpan payload mentah. Simpan juga metadata agar aplikasi bisa memutuskan kapan data masih segar, kapan boleh disajikan sebagai stale, dan kapan harus di-refresh.

type CacheEnvelope<T> = {
  data: T;
  cachedAt: number;
  softExpiresAt: number;
  hardExpiresAt: number;
};

Helper TTL dan jitter

function withJitter(baseSeconds: number, spread = 0.2): number {
  const delta = baseSeconds * spread;
  const jittered = baseSeconds + (Math.random() * 2 - 1) * delta;
  return Math.max(1, Math.floor(jittered));
}

function nowMs(): number {
  return Date.now();
}

Jika TTL dasar 300 detik dan spread 20%, maka key akan kedaluwarsa di rentang yang sedikit berbeda. Ini mengurangi risiko banyak key habis bersamaan.

Contoh fungsi baca cache-aside

type UserProfile = {
  id: string;
  username: string;
  display_name: string | null;
  avatar_url: string | null;
  updated_at: string;
};

type Deps = {
  redis: {
    get(key: string): Promise<string | null>;
    set(key: string, value: string, opts?: { ex?: number; nx?: boolean }): Promise<unknown>;
    del(key: string): Promise<unknown>;
  };
  supabase: {
    from(table: string): {
      select(columns: string): {
        eq(column: string, value: string): {
          single(): Promise<{ data: UserProfile | null; error: unknown }>;
        };
      };
    };
  };
};

const HARD_TTL_SECONDS = 300;
const SOFT_TTL_SECONDS = 60;
const LOCK_TTL_SECONDS = 10;

function profileKey(userId: string) {
  return `user:profile:${userId}`;
}

function profileLockKey(userId: string) {
  return `lock:user:profile:${userId}`;
}

async function getUserProfile(userId: string, deps: Deps): Promise<UserProfile | null> {
  const key = profileKey(userId);
  const lockKey = profileLockKey(userId);
  const raw = await deps.redis.get(key);
  const now = nowMs();

  if (raw) {
    const cached = JSON.parse(raw) as CacheEnvelope<UserProfile | null>;

    if (cached.softExpiresAt > now) {
      return cached.data;
    }

    void refreshUserProfileInBackground(userId, deps, lockKey);
    return cached.data;
  }

  const lockAcquired = await deps.redis.set(lockKey, "1", { ex: LOCK_TTL_SECONDS, nx: true });

  if (lockAcquired) {
    try {
      const fresh = await fetchUserProfileFromDb(userId, deps);
      await writeUserProfileCache(userId, fresh, deps);
      return fresh;
    } finally {
      await deps.redis.del(lockKey);
    }
  }

  await sleep(50);
  const secondTry = await deps.redis.get(key);
  if (secondTry) {
    const cached = JSON.parse(secondTry) as CacheEnvelope<UserProfile | null>;
    return cached.data;
  }

  return fetchUserProfileFromDb(userId, deps);
}

async function refreshUserProfileInBackground(userId: string, deps: Deps, lockKey?: string) {
  const actualLockKey = lockKey ?? profileLockKey(userId);
  const lockAcquired = await deps.redis.set(actualLockKey, "1", { ex: LOCK_TTL_SECONDS, nx: true });
  if (!lockAcquired) return;

  try {
    const fresh = await fetchUserProfileFromDb(userId, deps);
    await writeUserProfileCache(userId, fresh, deps);
  } finally {
    await deps.redis.del(actualLockKey);
  }
}

async function fetchUserProfileFromDb(userId: string, deps: Deps): Promise<UserProfile | null> {
  const { data, error } = await deps.supabase
    .from("profiles")
    .select("id, username, display_name, avatar_url, updated_at")
    .eq("id", userId)
    .single();

  if (error) {
    throw error;
  }

  return data;
}

async function writeUserProfileCache(userId: string, data: UserProfile | null, deps: Deps) {
  const now = nowMs();
  const hardTtl = withJitter(HARD_TTL_SECONDS);
  const envelope: CacheEnvelope<UserProfile | null> = {
    data,
    cachedAt: now,
    softExpiresAt: now + SOFT_TTL_SECONDS * 1000,
    hardExpiresAt: now + hardTtl * 1000,
  };

  await deps.redis.set(profileKey(userId), JSON.stringify(envelope), { ex: hardTtl });
}

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Kenapa pendekatan ini bekerja

  • Miss biasa: hanya satu request yang diharapkan mengisi cache karena lock NX.
  • Stale-while-revalidate sederhana: saat soft TTL lewat, user tetap dapat data lama sebentar, sambil refresh berjalan di belakang.
  • Jitter: mencegah key populer kedaluwarsa bersamaan.
  • Fallback: jika lock tidak didapat, request lain menunggu singkat lalu mencoba membaca cache lagi, bukan langsung membanjiri database.

Catatan: implementasi lock ringan seperti ini cukup baik untuk banyak kasus praktis, tetapi bukan jaminan sinkronisasi sempurna lintas semua kegagalan jaringan atau proses. Tujuannya menekan duplikasi refill, bukan membangun sistem distributed lock kompleks.

Strategi invalidasi setelah update data

Pada pola cache-aside, masalah paling sulit biasanya bukan membaca data, melainkan menjaga cache tidak terlalu basi setelah write. Prinsip dasarnya: setelah update berhasil di Supabase, aplikasi harus menghapus atau memperbarui cache yang terkait.

Pilihan 1: delete-on-write

Strategi paling sederhana dan umum adalah menghapus key setelah update database berhasil. Request berikutnya akan miss lalu menarik data terbaru dari Supabase.

async function updateUserProfile(
  userId: string,
  patch: Partial<Pick<UserProfile, "display_name" | "avatar_url">>,
  deps: Deps & {
    supabase: any;
  }
) {
  const { data, error } = await deps.supabase
    .from("profiles")
    .update(patch)
    .eq("id", userId)
    .select("id, username, display_name, avatar_url, updated_at")
    .single();

  if (error) throw error;

  await deps.redis.del(profileKey(userId));
  return data;
}

Keunggulannya:

  • Sederhana.
  • Risiko menulis cache dengan data lama lebih kecil.
  • Tidak perlu memikirkan merge data parsial di cache.

Kelemahannya:

  • Request berikutnya tetap mengalami cache miss.
  • Jika ada banyak turunan key, invalidasi bisa lebih rumit.

Pilihan 2: write-through ringan setelah update

Jika Anda sudah mendapatkan row terbaru dari Supabase setelah update, Anda bisa langsung menulis cache baru, bukan sekadar menghapus. Ini mengurangi miss setelah write.

async function updateUserProfileAndWarmCache(userId: string, patch: Partial<UserProfile>, deps: Deps & { supabase: any }) {
  const { data, error } = await deps.supabase
    .from("profiles")
    .update(patch)
    .eq("id", userId)
    .select("id, username, display_name, avatar_url, updated_at")
    .single();

  if (error) throw error;

  await writeUserProfileCache(userId, data, deps);
  return data;
}

Pendekatan ini berguna bila endpoint update sering diikuti pembacaan data yang sama. Namun, Anda harus disiplin memastikan hasil yang ditulis ke cache memang representasi terbaru dari database.

Bagaimana jika satu entitas punya banyak key turunan?

Misalnya profil pengguna muncul dalam beberapa bentuk:

  • user:profile:{id}
  • user:summary:{id}
  • feed:item:{postId} yang mengandung snapshot profil

Di sinilah invalidasi mulai mahal. Beberapa praktik yang membantu:

  • Kurangi duplikasi payload; cache-kan objek per entitas jika memungkinkan.
  • Hindari menyimpan terlalu banyak snapshot yang sulit dilacak.
  • Jika perlu, gunakan versioning key atau namespace per jenis objek.
  • Untuk data yang sering berubah, pertimbangkan cache hanya untuk hasil agregat atau list, bukan semua turunan detailnya.

Consistency vs freshness: memilih kompromi yang tepat

Tidak ada cache yang gratis. Anda selalu menukar sebagian freshness demi latency yang lebih rendah dan beban query yang lebih ringan.

Saat lebih condong ke consistency

  • Setelah operasi penting, hapus cache segera.
  • Gunakan TTL lebih pendek.
  • Kurangi atau hindari penyajian stale pada data sensitif.
  • Jangan cache hasil yang bergantung pada otorisasi yang sangat dinamis tanpa key yang tepat.

Saat lebih condong ke freshness yang longgar

  • Gunakan soft TTL agar data stale singkat tetap bisa disajikan.
  • Pakai TTL lebih panjang untuk objek yang jarang berubah.
  • Fokus pada endpoint read-heavy seperti profil publik, katalog, konfigurasi, atau agregasi ringan.

Pertanyaan praktisnya bukan “apakah data bisa basi?”, melainkan “berapa lama data boleh basi tanpa merusak perilaku aplikasi?” Jika jawabannya nol atau hampir nol, cache mungkin bukan pilihan yang tepat.

Mencegah cache stampede dan thundering herd

1. Gunakan single-flight atau lock ringan

Saat key tidak ada, jangan biarkan setiap request menembak Supabase. Gunakan lock NX dengan TTL pendek agar satu request melakukan refill, sementara yang lain menunggu singkat atau menerima stale.

2. Tambahkan jitter pada TTL

Jika semua key dibuat dalam batch dengan TTL identik, mereka cenderung mati bersama. Jitter menyebarkan waktu kedaluwarsa dan mengurangi puncak beban.

3. Layani stale secara terbatas

Untuk data yang aman sedikit tertinggal, stale-while-revalidate sangat membantu. User mendapat respons cepat, database tidak mendadak dibanjiri refill.

4. Negative caching untuk data yang tidak ada

Jika ID tertentu sering dicari tetapi memang tidak ditemukan, Anda bisa cache nilai null dengan TTL pendek. Ini mencegah query berulang ke database untuk key yang sama.

Hati-hati dengan negative caching pada data yang mungkin segera muncul. Gunakan TTL pendek agar entitas baru tidak terlambat terlihat.

Kapan cache justru merugikan

  • Data terlalu sering berubah: invalidasi jadi lebih mahal daripada manfaatnya.
  • Query murah, kompleksitas tinggi: cache menambah titik gagal, debugging, dan biaya operasional tanpa penghematan berarti.
  • Ukuran payload besar: serialisasi, transfer jaringan, dan memori Redis bisa menjadi bottleneck baru.
  • Key salah desain: data lintas user bisa tercampur jika key tidak memasukkan dimensi yang benar seperti tenant, locale, atau permission scope.
  • Staleness tidak dapat diterima: misalnya kuota, inventori kritis, atau status transaksi yang harus akurat saat itu juga.

Prinsip yang sehat: cache hanya setelah Anda tahu query mana yang benar-benar panas, mahal, dan stabil polanya.

Checklist operasional untuk cache-aside di produksi

Metrik yang perlu dipantau

  • Hit ratio: berapa persen request dilayani dari cache.
  • Miss ratio: terutama miss pada key panas.
  • Refill rate: seberapa sering aplikasi mengisi ulang cache.
  • Stale reads: berapa banyak respons memakai data yang sudah lewat soft TTL.
  • Latency cache vs database: pastikan cache memang memberi manfaat nyata.
  • Error rate Redis/Upstash: timeout, koneksi gagal, atau rate limit.
  • Lock contention: terlalu banyak request berebut lock menandakan key tertentu sangat panas atau TTL kurang tepat.

Fallback saat cache down

Jika Redis/Upstash bermasalah, aplikasi sebaiknya tetap bisa membaca langsung dari Supabase selama database masih mampu menahan beban tambahan. Beberapa praktik aman:

  • Jika get cache gagal, log dan lanjut query ke Supabase.
  • Jika set cache gagal, jangan gagalkan request utama.
  • Pasang timeout ketat pada operasi cache agar tidak lebih lambat dari query database itu sendiri.
  • Pertimbangkan circuit breaker sederhana jika cache sedang error terus-menerus.

Debugging inkonsistensi cache

Saat user melaporkan data lama padahal database sudah berubah, telusuri hal berikut:

  1. Apakah key yang benar di-invalidasi? Sering kali masalahnya adalah ada lebih dari satu key untuk entitas yang sama.
  2. Apakah update dilakukan sebelum invalidasi? Jangan hapus cache lalu update database; urutan yang aman biasanya update database dulu, baru invalidasi atau tulis cache baru.
  3. Apakah ada race condition? Misalnya request A membaca data lama dari database tepat sebelum request B menyelesaikan update, lalu A menulis ulang cache dengan data lama.
  4. Apakah TTL terlalu panjang? TTL keras panjang tanpa soft refresh atau invalidasi sering memicu keluhan stale.
  5. Apakah key memasukkan dimensi konteks? Tenant, locale, role, atau filter query yang tidak masuk ke key bisa menghasilkan cache salah isi.

Untuk mempermudah investigasi, simpan metadata seperti cachedAt dan, jika relevan, updated_at dari row Supabase ke dalam payload cache. Saat ada masalah, Anda bisa membandingkan kapan cache dibuat dengan kapan data di database terakhir berubah.

Kesalahan umum yang sering terjadi

  • Menggunakan TTL sama untuk semua jenis data tanpa mempertimbangkan frekuensi perubahan.
  • Tidak memberi jitter sehingga banyak key mati bersamaan.
  • Tidak ada lock saat refill sehingga miss massal berubah menjadi stampede.
  • Menyimpan payload besar apa adanya tanpa memilih field yang benar-benar dibutuhkan.
  • Menganggap invalidasi cukup dengan satu key padahal ada banyak representasi data.
  • Membiarkan operasi cache melempar error hingga request utama ikut gagal, padahal Supabase masih sehat.

Rekomendasi implementasi yang pragmatis

Jika Anda baru mulai, jangan langsung membangun sistem cache yang terlalu rumit. Mulai dari langkah berikut:

  1. Pilih satu endpoint read-heavy yang jelas panas.
  2. Gunakan cache-aside dasar dengan key yang eksplisit.
  3. Tambahkan TTL keras dan jitter.
  4. Tambahkan soft TTL untuk stale-while-revalidate sederhana.
  5. Tambahkan lock ringan untuk refill single-flight.
  6. Terapkan delete-on-write setelah update berhasil.
  7. Pasang metrik hit ratio, stale reads, dan error cache.

Pendekatan ini sudah cukup untuk banyak aplikasi Supabase yang ingin menurunkan beban query Postgres tanpa masuk ke kompleksitas event-driven invalidation yang berat.

Penutup

Cache Aside di Supabase paling berguna ketika Anda ingin mempertahankan Supabase/Postgres sebagai sumber data utama, tetapi tetap menurunkan beban query read-heavy dengan Redis atau Upstash. Kunci keberhasilannya bukan sekadar menyimpan hasil query ke cache, melainkan mengelola umur data, mencegah stampede, dan menyiapkan invalidasi yang disiplin setelah write.

Jika Anda menerapkan TTL keras, soft TTL, jitter, dan single-flight ringan, Anda bisa mendapat manfaat cache tanpa terlalu sering tersandung data basi atau lonjakan query saat key populer kedaluwarsa. Sebaliknya, jika data sangat sensitif terhadap freshness atau pola aksesnya tidak cocok, lebih baik batasi atau hindari cache daripada menambah kompleksitas yang tidak perlu.