Status HTTP 499 (Client Closed Request) adalah kode non-standar yang diperkenalkan oleh NGINX ketika klien menutup koneksi sebelum server selesai mengirimkan respons. Pada aplikasi mobile berbasis React Native, pembatalan request via AbortController umum dilakukan saat pengguna berpindah navigasi (screen change) atau saat input form di-reset. Namun, jika backend tidak menangani pembatalan ini, database akan mengalami resource starvation akibat eksekusi orphan query.

Gejala: Lonjakan Status 499 dan Database Starvation

Masalah ini biasanya terdeteksi melalui anomali berikut pada metrik infrastruktur:

  • Spike status 499 pada NGINX: Log reverse proxy mencatat lonjakan HTTP 499 pada endpoint dengan pemrosesan berat.
  • Database CPU Spike: Utilisasi CPU pada instans PostgreSQL/MySQL mencapai 90-100% tanpa adanya peningkatan throughput respons 200 OK.
  • Connection Pool Exhaustion: Metrik connection pool aplikasi (misalnya HikariCP, pgxpool) menyentuh batas maksimum. Request baru gagal akibat timeout pool (connection pool exhausted).

Pola ini umumnya dipicu oleh pengguna yang berpindah-pindah tab navigasi dengan cepat pada aplikasi React Native. Setiap kali layar berganti, request sebelumnya dibatalkan di sisi client, namun backend tetap memproses beban kerja hingga tuntas.

Root Cause: Mekanisme Terbentuknya Orphan Query

Ketika React Native menjalankan abortController.abort(), runtime menghentikan stream TCP dan mengirim paket TCP RST/FIN ke reverse proxy. NGINX menerima penutupan ini dan mencatat status 499 ke log akses.

Masalah muncul pada komunikasi layer aplikasi ke database:

  1. Reverse proxy memutus koneksi upstream ke backend, atau socket koneksi HTTP backend mendeteksi EOF.
  2. Handler backend tidak memantau event penutupan koneksi client (cancellation event).
  3. Thread atau goroutine backend tetap melanjutkan alur kerja dan mengeksekusi SQL query kompleks ke database.
  4. Database mengalokasikan memori, I/O, dan core CPU untuk query yang hasilnya tidak akan pernah dibaca oleh client.

Solusi Backend: Propagasi Cancellation Context ke Database

Untuk menghentikan query yang berjalan di database, backend wajib mengalirkan sinyal pembatalan socket client langsung ke driver database. Driver database modern akan mengirimkan sinyal pembatalan out-of-band (misal: CancelRequest pada protokol PostgreSQL) ke engine DB.

Implementasi pada Go

Pada Go standard library, http.Request.Context() otomatis dibatalkan saat klien memutus koneksi TCP. Teruskan konteks tersebut langsung ke query database:

package main

import (
	"context"
	"database/sql"
	"errors"
	"log"
	"net/http"
)

func ProductAnalyticsHandler(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Context diteruskan ke QueryContext
		rows, err := db.QueryContext(ctx, `
			SELECT category, SUM(amount)
			FROM transactions
			GROUP BY category
			ORDER BY SUM(amount) DESC
		`)
		if err != nil {
			if errors.Is(err, context.Canceled) {
				// Socket client terputus; database membatalkan query
				log.Println("Client memutus koneksi. Query dibatalkan.")
				return
			}
			http.Error(w, "Query error", http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		w.WriteHeader(http.StatusOK)
	}
}

Implementasi pada Node.js / Express

Pada Node.js runtime standar, event close pada objek req menandakan stream telah berakhir. Gunakan signal tersebut untuk membatalkan query ke database client yang mendukung pembatalan (seperti knex atau pg):

const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool();

app.get('/api/analytics', async (req, res) => {
  const client = await pool.connect();
  let isAborted = false;

  req.on('close', async () => {
    if (!res.writableEnded) {
      isAborted = true;
      // Batalkan query backend PostgreSQL via pid backend process
      try {
        await pool.query('SELECT pg_cancel_backend($1)', [client.processID]);
      } catch (err) {
        // Handle cancel backend error
      } finally {
        client.release();
      }
    }
  });

  try {
    const result = await client.query(`
      SELECT category, SUM(amount) 
      FROM transactions 
      GROUP BY category
    `);
    if (!isAborted) {
      res.json(result.rows);
      client.release();
    }
  } catch (err) {
    if (!isAborted) {
      client.release();
      res.status(500).json({ error: err.message });
    }
  }
});

Solusi Frontend: Lifecycle AbortController & Debouncing

Penghentian orphan query di backend perlu diimbangi dengan efisiensi pengiriman request dari sisi React Native. Request storm akibat unmounting yang cepat harus ditekan dengan membersihkan lifecycle dan menerapkan debounce.

Debounce & Cleanup pada React Native

import React, { useEffect, useState, useRef } from 'react';
import { View, TextInput, Text } from 'react-native';

export function SearchScreen() {
  const [query, setQuery] = useState('');
  const abortControllerRef = useRef(null);

  useEffect(() => {
    if (!query) return;

    // Batalkan request aktif sebelumnya sebelum membuat request baru
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    const controller = new AbortController();
    abortControllerRef.current = controller;

    const timer = setTimeout(async () => {
      try {
        const response = await fetch(`https://api.internal/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        // Proses response
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Fetch error:', error);
        }
      }
    }, 300); // 300ms debounce

    return () => {
      clearTimeout(timer);
      controller.abort(); // Cleanup saat screen unmount
    };
  }, [query]);

  return (
    <View>
      <TextInput value={query} onChangeText={setQuery} placeholder="Cari..." />
    </View>
  );
}

Konfigurasi Reverse Proxy & Database Timeout

Selain context-cancellation pada kode aplikasi, terapkan guardrail pada level infrastruktur:

  • statement_timeout (PostgreSQL): Terapkan batas waktu eksekusi query default (misal: SET statement_timeout = '15s';) untuk mematikan query yang tersangkut.
  • proxy_ignore_client_abort: Pada NGINX, jangan mengaktifkan opsi proxy_ignore_client_abort on; kecuali endpoint tersebut memproses transaksi non-idempotent yang harus tetap selesai (misalnya integrasi webhook pembayaran). Membiarkan opsi ini nonaktif memastikan NGINX segera menutup koneksi upstream saat klien disconnect.