Server Actions dan Route Handlers di Next.js dirancang untuk menangani mutasi data berdurasi singkat dalam siklus request-response HTTP. Menjalankan operasi komputasi berat—seperti kompilasi PDF skala besar, pemrosesan video, sinkronisasi data ribuan baris, atau interaksi API pihak ketiga yang lambat—langsung di dalam Server Action memicu tiga masalah mendasar: execution timeout (10–60 detik pada lingkungan serverless/edge), event loop blocking pada runtime Node.js, dan hilangnya status eksekusi jika koneksi terputus di tengah jalan.

Solusi yang tepat adalah melakukan decoupling beban kerja: Server Action hanya bertindak sebagai producer yang mendaftarkan pekerjaan ke antrean (queue), sementara satu atau lebih worker independen mengeksekusinya secara asinkron di latar belakang.

Arsitektur Decoupling: Next.js, Redis, dan BullMQ

Pola arsitektur ini memisahkan siklus hidup web server Next.js dari siklus pemrosesan background job. Next.js menerima permintaan klien, memvalidasi payload, memasukkan job ke Redis melalui BullMQ dalam hitungan milidetik, lalu segera mengembalikan respons berisi jobId ke antarmuka pengguna.

Alternatif lebih instan tanpa infrastruktur Redis mandiri: Gunakan Inngest atau Trigger.dev. Gunakan BullMQ + Redis jika Anda memerlukan kontrol penuh atas resource, bebas vendor lock-in, dan meminimalkan biaya transmisi data.

Worker berjalan sebagai proses Node.js terpisah (misalnya dalam container Docker tersendiri atau process manager seperti PM2). Worker mendengarkan (listening) antrean di Redis, mengambil job sesuai batas konkurensi, dan mengeksekusi logika berat tanpa membebani server web aplikasi utama.

Setup Koneksi Redis dan Queue Producer

BullMQ membutuhkan driver Redis yang stabil. Gunakan ioredis dengan opsi maxRetriesPerRequest: null karena ini adalah persyaratan mutlak dari arsitektur blocking BullMQ.

Pertama, inisialisasi instance queue terpusat di direktori aplikasi Next.js Anda (misal: lib/queue.ts):

import { Queue } from 'bullmq';
import IORedis from 'ioredis';

const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';

export const redisConnection = new IORedis(redisUrl, {
  maxRetriesPerRequest: null,
  enableReadyCheck: false,
});

export const reportQueue = new Queue('report-processing', {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 2000, // Mulai dari 2 detik, lalu 4 detik, 8 detik
    },
    removeOnComplete: {
      age: 3600, // Simpan histori job sukses selama 1 jam
      count: 1000,
    },
    removeOnFail: {
      age: 86400, // Simpan histori gagal selama 24 jam untuk debugging
    },
  },
});

Implementasi Producer di Server Action

Gunakan queue tersebut di dalam Server Action. Action menerima input formulir, melakukan validasi skema dasar, lalu memasukkan payload ke dalam antrean. Fungsi selesai dalam tempo < 50ms tanpa menunggu pemrosesan data selesai.

'use server';

import { reportQueue } from '@/lib/queue';

interface GenerateReportInput {
  userId: string;
  reportType: 'SALES' | 'INVENTORY';
  dateRange: { start: string; end: string };
}

export async function createReportAction(input: GenerateReportInput) {
  if (!input.userId || !input.reportType) {
    return { error: 'Payload tidak valid.' };
  }

  try {
    const job = await reportQueue.add('generate-report', {
      ...input,
      submittedAt: new Date().toISOString(),
    });

    return {
      success: true,
      jobId: job.id,
      status: 'QUEUED',
    };
  } catch (error) {
    console.error('Gagal memasukkan job ke antrean:', error);
    return { error: 'Terjadi kegagalan sistem internal.' };
  }
}

Membangun Worker Process Terpisah

Jangan menjalankan worker BullMQ di dalam Next.js API Routes atau Server Actions. Runtime Next.js bersifat ephemeral dan mematikan thread saat request selesai. Worker harus berjalan sebagai proses terpisah (misalnya worker.ts di root repositori atau repositori terpisah).

import { Worker, Job } from 'bullmq';
import IORedis from 'ioredis';

const redisConnection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379', {
  maxRetriesPerRequest: null,
});

interface ReportJobData {
  userId: string;
  reportType: string;
  dateRange: { start: string; end: string };
}

async function processReport(job: Job<ReportJobData>) {
  await job.updateProgress(10);
  
  // Simulasi komputasi intensif / query berat
  console.log(`[Worker] Memproses job ${job.id} untuk user ${job.data.userId}`);
  
  // Simulasi langkah kerja panjang
  await new Promise((resolve) => setTimeout(resolve, 5000));
  await job.updateProgress(60);

  await new Promise((resolve) => setTimeout(resolve, 5000));
  await job.updateProgress(100);

  return {
    downloadUrl: `https://storage.example.com/reports/${job.id}.pdf`,
    generatedAt: new Date().toISOString(),
  };
}

const worker = new Worker('report-processing', processReport, {
  connection: redisConnection,
  concurrency: 5, // Batasi 5 job bersamaan per instance worker
});

worker.on('completed', (job) => {
  console.log(`[Worker] Job ${job.id} berhasil diselesaikan.`);
});

worker.on('failed', (job, err) => {
  console.error(`[Worker] Job ${job?.id} gagal setelah ${job?.attemptsMade} percobaan:`, err.message);
});

Jalankan worker via terminal atau Docker:

npx tsx worker.ts

Strategi Pelaporan Status Asinkron ke Antarmuka (UI)

Setelah klien mendapatkan jobId dari Server Action, UI perlu memantau progres tugas. Ada dua pendekatan standar: Polling via Server Action berkala atau Server-Sent Events (SSE) melalui Route Handler.

Metode paling sederhana dan efisien untuk kebanyakan kasus adalah polling terkontrol menggunakan Server Action pengecekan status:

'use server';

import { reportQueue } from '@/lib/queue';

export async function getJobStatusAction(jobId: string) {
  const job = await reportQueue.getJob(jobId);

  if (!job) {
    return { state: 'NOT_FOUND' };
  }

  const state = await job.getState(); // 'waiting' | 'active' | 'completed' | 'failed'
  const progress = job.progress;
  const result = job.returnvalue;
  const failedReason = job.failedReason;

  return {
    id: job.id,
    state,
    progress,
    result: state === 'completed' ? result : null,
    error: state === 'failed' ? failedReason : null,
  };
}

Di sisi klien Next.js (Client Component), panggil Server Action tersebut setiap interval 2–3 detik sampai status bernilai completed atau failed.

Checklist Observabilitas dan Metrik Antrean

Menjalankan sistem queue tanpa monitoring berisiko mengakibatkan akumulasi memori di Redis atau silent failure. Pastikan poin-poin metrik berikut terawasi sebelum rilis ke production:

  • Queue Depth (Waiting Jobs): Jika metrik antrean yang menunggu terus bertambah tanpa penurunan, tingkatkan parameter concurrency atau lakukan horizontal scaling pada instance worker.
  • Job Duration Latency: Catat waktu sejak job didaftarkan hingga job selesai. Lonjakan metrik ini menandakan degradasi performa I/O database atau CPU worker.
  • Dead Letter Queue (DLQ): Pantau jumlah job dengan status failed yang telah menghabiskan limit attempts. Selalu sediakan alerting via log atau webhook ketika DLQ bertambah.
  • Redis Memory Footprint: Pastikan Anda mengaktifkan opsi pembersihan otomatis seperti removeOnComplete dan removeOnFail pada options queue untuk mencegah memory leak di Redis instance.
  • Dashboard Monitoring: Pasang Bull-Board pada internal dashboard admin untuk keperluan retry manual dan inspeksi visual status antrean tanpa mengakses CLI Redis langsung.