Gejala: Lonjakan Latensi dan Error 504 Gateway Timeout

Saat fitur upload media diluncurkan ke aplikasi mobile React Native, sistem backend sering kali menunjukkan anomali performa. Endpoint baca ringan seperti GET /api/v1/feed atau GET /api/v1/profile tiba-tiba mengalami lonjakan latensi p99 secara drastis, disusul oleh kemunculan galat 504 Gateway Timeout pada reverse proxy.

Metrik sistem memperlihatkan paradoks berikut:

  • Penggunaan CPU dan memori pada node backend (Node.js atau Go) terpantau rendah.
  • Jumlah koneksi aktif pada application worker mencapai batas maksimal (pool saturation).
  • Antrean request masuk (backlog queue) menumpuk di reverse proxy.

Masalah ini terjadi bukan karena komputasi pemrosesan file yang berat, melainkan karena ketiadaan alokasi worker thread/goroutine yang tersisa untuk melayani request baru. Kondisi ini disebut worker starvation.

Root Cause Analysis: Efek Slow POST dan Unbuffered Streaming

Klien mobile React Native beroperasi pada jaringan seluler yang fluktuatif (2G, 3G, atau 4G dengan sinyal marginal). Ketika pengguna mengunggah file media berukuran 5MB–10MB, transmisi HTTP POST multipart/form-data berlangsung perlahan akibat throughput kecil dan tingginya angka packet retransmission.

Akar kegagalan terletak pada konfigurasi reverse proxy (seperti Nginx) yang mengalirkan body request secara langsung ke upstream backend tanpa mekanisme buffer:

# Konfigurasi bermasalah: data dialirkan mentah ke upstream
proxy_request_buffering off;

Ketika proxy_request_buffering dinonaktifkan, reverse proxy langsung membuka koneksi ke backend worker segera setelah header HTTP diterima. Worker backend dipaksa menunggu chunk data TCP tiba satu demi satu melewati jaringan seluler klien.

Dampaknya terhadap arsitektur backend:

  • Node.js: Event loop tetap berjalan, namun memory retention membengkak untuk stream chunk parsial, dan slot koneksi pool HTTP/keep-alive habis.
  • Go / Thread-pool model (Puma, Gunicorn): Setiap goroutine atau thread sistem terblokir pada operasi io.Reader atau read() syscall selama puluhan detik hanya untuk menunggu transmisi I/O jaringan klien selesai.

Dengan 50 request upload lambat berjalan serentak pada instance dengan 50 worker slot, seluruh backend berhenti merespons request lain sekalipun request tersebut hanya membutuhkan eksekusi database query 5 milidetik.

Solusi Jangka Pendek: Buffering pada Reverse Proxy

Langkah mitigasi tercepat di level infrastruktur tanpa mengubah kode aplikasi adalah mengaktifkan request buffering pada reverse proxy. Nginx bertindak sebagai pelindung upstream: koneksi seluler lambat ditahan di edge, dan payload disimpan sementara di memory atau disk cache lokal Nginx.

# /etc/nginx/conf.d/api.conf
location /api/v1/media/upload {
    proxy_pass http://backend_upstream;
    
    # Wajib aktif: tahan payload hingga transfer selesai 100%
    proxy_request_buffering on;
    
    # Batas ukuran payload
    client_max_body_size 20M;
    
    # Alokasi buffer memori sebelum spillover ke disk temporer
    client_body_buffer_size 256k;
    client_body_temp_path /var/cache/nginx/client_temp 1 2;
    
    # Timeout handling untuk jaringan lambat
    client_body_timeout 60s;
    proxy_connect_timeout 5s;
    proxy_read_timeout 30s;
    proxy_send_timeout 30s;
}

Dengan proxy_request_buffering on, Nginx baru membuka koneksi TCP ke backend upstream setelah byte terakhir payload selesai diterima dari klien React Native. Backend menerima payload utuh melalui loopback network berkecepatan tinggi dalam hitungan milidetik, membebaskan worker seketika.

Trade-off: Pendekatan ini meningkatkan I/O disk pada server Nginx jika ukuran body melebihi client_body_buffer_size, dan backend tidak dapat menampilkan metrik progress upload secara real-time kepada pengguna.

Solusi Definitif: Migrasi Direct Upload via Presigned URL

Menjadikan backend application sebagai perantara transfer file biner adalah anti-pattern dalam skala produksi. Arsitektur ideal mengharuskan klien React Native mengunggah objek biner langsung ke object storage (S3, Google Cloud Storage) menggunakan Presigned URL.

1. Endpoint Backend: Generate Presigned URL

Backend hanya menangani autentikasi dan pembuatan URL sementara berdurasi pendek. Implementasi Go:

package main

import (
	"context"
	"time"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

func GetPresignedUploadURL(s3Client *s3.Client, bucket, objectKey string) (string, error) {
	presignClient := s3.NewPresignClient(s3Client)
	
	req, err := presignClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(objectKey),
	}, s3.WithPresignExpires(15*time.Minute))
	if err != nil {
		return "", err
	}
	return req.URL, nil
}

2. Upload dari React Native

Klien React Native meminta signed URL terlebih dahulu, lalu mengeksekusi HTTP PUT langsung ke bucket penyimpanan menggunakan XMLHttpRequest untuk melacak progress tanpa membebani backend:

export const uploadDirectToStorage = (presignedUrl, fileUri, mimeType) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', presignedUrl);
    xhr.setRequestHeader('Content-Type', mimeType);

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const percentComplete = (event.loaded / event.total) * 100;
        console.log(`Upload: ${percentComplete.toFixed(2)}%`);
      }
    };

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(true);
      } else {
        reject(new Error(`Upload failed with status ${xhr.status}`));
      }
    };

    xhr.onerror = () => reject(new Error('Network error during upload'));
    xhr.send({ uri: fileUri, type: mimeType });
  });
};

Verifikasi dan Monitoring

Untuk memastikan worker starvation teratasi, monitor rasio waktu request pada access log Nginx menggunakan dua variabel:

  • $request_time: Total durasi transaksi antara klien mobile dan Nginx.
  • $upstream_response_time: Durasi yang dihabiskan backend worker untuk memproses payload.

Jika $request_time bernilai 45 detik sementara $upstream_response_time tercatat 0.05 detik, maka buffering berjalan optimal. Klien mobile yang lambat diisolasi di reverse proxy, sementara worker backend terlindungi dan tetap responsif melayani lalu lintas transaksi utama.