Inkonsistensi read-after-write sering muncul pada aplikasi berbasis Inertia.js saat developer memindahkan operasi mutasi data ke queue worker. Dalam arsitektur web sinkron tradisional, siklus request-response menjamin bahwa saat browser merender ulang tampilan, data di database dan cache telah terbarukan. Namun, ketika proses penulisan dialihkan ke background job, respons HTTP 303 (Redirect) dari Inertia sering kali tiba di browser dan memicu pengambilan props baru sebelum worker selesai memproses transaksi database dan mengosongkan cache.

Akibatnya, browser menampilkan data usang (stale data). Artikel ini membedah arsitektur solusi deterministik untuk mengeliminasi race condition tersebut menggunakan database transaction afterCommit, cache tagging di level worker, serta koordinasi WebSocket untuk memicu selective partial reload pada Inertia router.

Anatomi Masalah: Race Condition pada Inertia Lifecycle

Inertia.js mengandalkan respons redirect setelah permintaan POST, PUT, atau DELETE. Siklus yang memicu stale read berjalan sebagai berikut:

  1. Klien mengirim form mutasi via router.post().
  2. Controller backend memvalidasi payload, mendispatch job ke antrean (Redis/DB), lalu langsung mengembalikan to_route() atau back().
  3. Inertia menerima respons HTTP redirect dan secara otomatis mengirimkan request GET untuk rute tujuan.
  4. Controller rute tujuan membaca data dari database atau cache layer.
  5. Masalah: Queue worker belum mengambil atau menyelesaikan eksekusi job. Database belum ter-update dan cache belum di-invalidasi. Klien merender data lama.
Alternatif paling hemat: Jika proses mutasi selesai di bawah 300-500ms, jangan gunakan queue. Jalankan sinkron dalam transaksi database untuk menghindari kompleksitas WebSocket dan worker synchronization.

Langkah 1: Proteksi Mutasi dengan afterCommit

Kesalahan fatal yang sering terjadi adalah mendispatch queue job di dalam blok transaksi sebelum transaksi selesai di-commit. Jika worker memproses job lebih cepat daripada proses commit database utama, worker akan membaca data transaksi yang belum selesai (dirty read) atau bahkan gagal menemukan relasi data terkait.

Gunakan callback afterCommit atau set flag $afterCommit = true pada job Laravel:

namespace App\Http\Controllers;

use App\Http\Requests\UpdateProjectRequest;
use App\Jobs\ProcessProjectMetrics;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;

class ProjectController extends Controller
{
    public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
    {
        DB::transaction(function () use ($request, $project) {
            $project->update($request->validated());

            // Dispatch hanya jika transaksi database berhasil commit
            ProcessProjectMetrics::dispatch($project->id, auth()->id())
                ->afterCommit();
        });

        // Mengembalikan status sementara tanpa menunggu kalkulasi berat
        return back()->with('status', 'Pembaruan sedang diproses.');
    }
}

Langkah 2: Worker Execution, Cache Tagging, dan Broadcasting

Queue worker bertanggung jawab mengeksekusi komputasi berat, membersihkan entri cache yang relevan, dan memancarkan (broadcast) sinyal bahwa state telah konsisten. Hindari pembersihan cache di controller sebelum worker selesai.

namespace App\Jobs;

use App\Events\ProjectUpdatedEvent;
use App\Models\Project;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;

class ProcessProjectMetrics implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $projectId,
        public int $userId
    ) {}

    public function handle(): void
    {
        $project = Project::findOrFail($this->projectId);

        // Eksekusi mutasi atau kalkulasi berat
        $project->recalculateMetrics();
        $project->save();

        // Invalidasi cache secara terarah via tagging
        Cache::tags(['projects', "project:{$this->projectId}"])->flush();

        // Broadcast event completion ke private channel user
        broadcast(new ProjectUpdatedEvent($this->projectId, $this->userId));
    }
}

Event broadcasting harus mengimplementasikan ShouldBroadcastNow jika menggunakan Redis/Reverb agar event dikirim instan tanpa masuk antrean kedua:

namespace App\Events;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class ProjectUpdatedEvent implements ShouldBroadcastNow
{
    public function __construct(
        public int $projectId,
        public int $userId
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("App.Models.User.{$this->userId}"),
        ];
    }

    public function broadcastAs(): string
    {
        return 'project.updated';
    }
}

Langkah 3: Sinkronisasi Frontend dengan Selective Reload

Alih-alih melakukan full page reload atau polling berkala via HTTP yang membebani server, tangkap event penyelesaian mutasi di sisi klien menggunakan Laravel Echo. Gunakan metode router.reload() dengan opsi only untuk meminta partial props yang terdampak saja.

// Implementasi pada Vue 3 Composition API / Script Setup
import { onMounted, onUnmounted } from 'vue';
import { router } from '@inertiajs/vue3';

const props = defineProps<{
    project: { id: number; name: string; metrics: object };
    auth: { user: { id: number } };
}>();

let channel: any = null;

onMounted(() => {
    channel = window.Echo.private(`App.Models.User.${props.auth.user.id}`)
        .listen('.project.updated', (event: { projectId: number }) => {
            if (event.projectId === props.project.id) {
                // Tarik ulang props 'project' secara selektif dari backend
                router.reload({
                    only: ['project'],
                    preserveScroll: true,
                    preserveState: true,
                });
            }
        });
});

onUnmounted(() => {
    if (channel) {
        window.Echo.leave(`App.Models.User.${props.auth.user.id}`);
    }
});

Trade-offs dan Pertimbangan Sistem

  • Kompleksitas Infrastruktur: Pendekatan ini membutuhkan WebSocket server aktif (seperti Laravel Reverb atau Soketi) dan worker Redis yang andal.
  • Connection Leaks: Pastikan selalu memanggil Echo.leave() pada hook onUnmounted (Vue) atau cleanup callback useEffect (React) agar klien tidak menumpuk subscription.
  • Graceful Degradation: Jika koneksi WebSocket terputus, sediakan fallback berupa tombol sinkronisasi manual atau optimistic indicator di antarmuka pengguna agar status sistem tetap transparan.