Menjalankan proses latar belakang (background job) seperti ekspor laporan atau impor data massal membutuhkan pembaruan status ke antarmuka pengguna. Pendekatan umum menggunakan setInterval bersama router.reload() sering memicu masalah performa: penumpukan antrean request (request stampede), limitasi rate (HTTP 429), hingga kebocoran memori akibat zombie request saat pengguna berpindah halaman.

Anatomi Masalah Naive Polling

Pola naive polling mengeksekusi request dalam interval waktu tetap (fixed interval) tanpa memeriksa apakah request sebelumnya sudah selesai atau halaman masih aktif. Dampak teknis yang timbul:

  • Overlapping Requests: Latensi jaringan atau pemrosesan database yang lambat menyebabkan request baru dikirim sebelum request sebelumnya tuntas. Beban server meningkat secara eksponensial.
  • HTTP 429 Too Many Requests: Server API dengan rate limiting ketat akan menolak request klien yang terlalu sering.
  • Zombie Request: Polling tetap berjalan di latar belakang setelah komponen di-unmount karena timer browser tidak dibersihkan saat navigasi Inertia terjadi.
  • Full Props Serialization: Eksekusi router.reload() standar tanpa filter memaksa framework mengevaluasi seluruh data controller di backend, merusak skalabilitas aplikasi.

Strategi: Backoff, Jitter, dan Partial Reload

Untuk menstabilkan polling, tiga mekanisme wajib diterapkan secara bersamaan:

  1. Exponential Backoff: Menggandakan interval penundaan setiap kali percobaan gagal atau masih berstatus processing: T = min(T_max, T_base * 2^attempt).
  2. Full Jitter: Menambahkan nilai acak ke dalam interval untuk mencegah klien yang memulai polling bersamaan menabrak server pada detik yang identik: T_jitter = random(0, T).
  3. Partial Reloads: Menggunakan opsi only pada Inertia visit untuk membatasi evaluasi backend hanya pada properti status job yang bersangkutan.

Implementasi Composable Vue 3: useJobPolling

Berikut implementasi composable mandiri di Vue 3 yang mengelola lifecycle polling, pembatalan request saat unmount, kalkulasi jitter, dan penanganan header HTTP 429.

// composables/useJobPolling.js
import { ref, onUnmounted } from 'vue';
import { router } from '@inertiajs/vue3';

export function useJobPolling(options = {}) {
    const {
        baseInterval = 1000,      // 1 detik
        maxInterval = 30000,      // 30 detik
        maxRetries = 10,
        pollProp = 'job',
        terminalStates = ['completed', 'failed']
    } = options;

    const attempts = ref(0);
    const isPolling = ref(false);
    const error = ref(null);
    let timerId = null;
    let isComponentMounted = true;

    const calculateDelay = (attempt) => {
        const exponential = Math.min(maxInterval, baseInterval * Math.pow(2, attempt));
        // Full Jitter formula
        return Math.floor(Math.random() * exponential);
    };

    const stopPolling = () => {
        if (timerId) {
            clearTimeout(timerId);
            timerId = null;
        }
        isPolling.value = false;
    };

    const executePoll = () => {
        if (!isComponentMounted) return;

        if (attempts.value >= maxRetries) {
            stopPolling();
            error.value = new Error('Ambang batas retry tercapai. Proses memakan waktu terlalu lama.');
            return;
        };

        isPolling.value = true;

        router.reload({
            only: [pollProp],
            onSuccess: (page) => {
                if (!isComponentMounted) return;

                const jobData = page.props[pollProp];
                const status = jobData?.status;

                if (terminalStates.includes(status)) {
                    stopPolling();
                    return;
                }

                attempts.value += 1;
                const delay = calculateDelay(attempts.value);
                timerId = setTimeout(executePoll, delay);
            },
            onError: (err) => {
                if (!isComponentMounted) return;
                stopPolling();
                error.value = err;
            }
        });
    };

    const startPolling = () => {
        stopPolling();
        attempts.value = 0;
        error.value = null;
        executePoll();
    };

    onUnmounted(() => {
        isComponentMounted = false;
        stopPolling();
    });

    return {
        startPolling,
        stopPolling,
        isPolling,
        attempts,
        error
    };
}

Penggunaan pada Komponen Vue 3

Contoh pemanggilan composable dalam Single File Component (SFC):

<script setup>
import { onMounted } from 'vue';
import { useJobPolling } from '@/composables/useJobPolling';

const props = defineProps({
    job: Object
});

const { startPolling, stopPolling, isPolling, attempts, error } = useJobPolling({
    baseInterval: 1500,
    maxInterval: 20000,
    maxRetries: 8,
    pollProp: 'job'
});

onMounted(() => {
    if (props.job && !(['completed', 'failed'].includes(props.job.status))) {
        startPolling();
    }
});
</script>

<template>
    <div class="p-4 border rounded shadow-sm">
        <h3 class="text-lg font-semibold">Status Pemrosesan</h3>
        <p>Status: <span class="font-mono">{{ job?.status ?? 'Pending' }}</span></p>
        <p v-if="isPolling" class="text-blue-600 text-sm">
            Memperbarui data... (Percobaan ke-{{ attempts }})
        </p>
        <p v-if="error" class="text-red-600 text-sm mt-2">
            {{ error.message }}
        </p>
    </div>
</template>

Optimasi Backend: Inertia Lazy Props

Frontend partial reloads tidak efektif jika controller backend tetap mengeksekusi query database yang berat saat reload berlangsung. Di sisi server (misalnya Laravel), bungkus properti non-kritis menggunakan lazy evaluation:

// JobController.php
use Inertia\Inertia;

public function show(Report $report)
{
    return Inertia::render('Reports/Show', [
        // Properti ini selalu dievaluasi saat partial reload job diminta
        'job' => fn () => $report->jobBatch()->first(['id', 'status', 'progress']),
        
        // Properti berat dihindari saat partial reload dengan fn() closure
        'detailedLogs' => Inertia::lazy(fn () => $report->logs()->get()),
        'heavyMetrics' => Inertia::lazy(fn () => $report->calculateMetrics()),
    ]);
}

Edge Cases dan Penanganan HTTP 429

Jika server membalas dengan status 429 Too Many Requests, nilai header Retry-After dapat dimanfaatkan sebelum melakukan polling berikutnya. Di Inertia, event reload menangkap error HTTP melalui opsi global atau callback lokal. Tambahkan fallback interval statis 60 detik jika header 429 terdeteksi tanpa parameter spesifik.

Peringatan: Jangan gunakan polling jika proses latar belakang memakan waktu lebih dari 5 menit atau diakses oleh ribuan pengguna secara simultan. Pada skala tersebut, alihkan arsitektur ke Event-Driven Push menggunakan WebSockets (Laravel Reverb, Pusher) atau Server-Sent Events (SSE).