Go Fiber menawarkan performa tinggi melalui fasthttp dan dukungan prefork, tapi ketika sistem melambung dari 1x menjadi 10x permintaan per detik, arsitektur menjadi penentu utama. Artikel ini langsung menjawab: kapan memilih event-driven berbasis goroutine non-blocking dibandingkan model thread pool worker per request di Go Fiber, serta bagaimana menjaga biaya operasional, observabilitas, dan maintainability saat tim tumbuh.

Di tingkat yang lebih tinggi, event-driven akan mempertahankan latency rendah untuk workload I/O-heavy, sementara thread pool memberikan batasan eksplisit untuk pekerjaan blocking. Kami membahas trade-off tersebut, bagaimana memonitor keduanya, serta strategi migrasi atau coexistence untuk memastikan skalabilitas jangka panjang.

Dasar Model Event-Driven vs Threaded di Go Fiber

Go Fiber sendiri dibangun di atas fasthttp, yang mengandalkan loop I/O event-driven. Goroutine ringan menjadi kendaraan paling natural untuk memanfaatkan model ini: setiap request diproses oleh goroutine, dan operasi blocking bisa dihindari dengan channel atau worker pool internal. Berikut contoh handler sederhana yang mengirim pekerjaan ke goroutine non-blocking menggunakan channel:

func main() {
    app := fiber.New()
    workCh := make(chan func(), 100)

    go func() {
        for task := range workCh {
            task()
        }
    }()

    app.Get("/event", func(c *fiber.Ctx) error {
        done := make(chan struct{})
        workCh <- func() {
            // operasi yang aman dijalankan di goroutine
            time.Sleep(50 * time.Millisecond)
            close(done)
        }
        <-done
        return c.SendString("selesai")
    })

    app.Listen(":3000")
}

Dalam pendekatan ini, goroutine tidak dibatasi, sehingga untuk I/O-bound ringan latency minimal. Namun jika handler melakukan operasi blocking berat, goroutine tanpa batas bisa menimbulkan lock contention dan spike CPU.

Alternatifnya adalah thread pool worker per request: buat pool terbatas yang mengeksekusi handler atau pekerjaan blocking. Meskipun Go tidak mengekspos thread manual, kita bisa menggunakan channel dengan buffer tetap untuk mengontrol concurrency, memaksa request menunggu slot worker sehingga jumlah thread sistem tidak melonjak.

Kapan Memilih Event-Driven (Goroutine Non-blocking)?

Model ini unggul ketika request umumnya bersifat I/O non-blocking—misalnya memanggil layanan eksternal asinkron, membaca dari cache, atau memproses JSON ringan. Keunggulan utamanya:

  • CPU & latency: latency rendah karena tidak ada antrean eksplisit, goroutine siap dieksekusi secepat scheduler Go memanggil.
  • Lock contention: lebih sedikit jika kita menggunakan channel dan pipeline tanpa objek bersama yang berat.
  • Maintainability: kode tetap ringkas dan mengikuti paradigma Go asli, sehingga tim Go baru dapat cepat memahami.

Tetapi pastikan:

  • Jangan menjalankan operasi blocking lama (disk I/O, hashing berat) langsung di handler. Gunakan worker pool terpisah atau library seperti golang.org/x/sync/errgroup.
  • Monitor goroutine count dan garbage collector (misalnya dengan runtime/pprof atau expvar) karena jumlah goroutine bisa meningkat pesat saat beban.

Kapan Memilih Model Thread Pool Worker per Request?

Model ini lebih cocok jika Anda menghadapi operasi blocking intensif atau lisensi database yang membatasi koneksi:

  • Control concurrency: Dengan worker pool (misalnya 100 worker), Anda membatasi jumlah goroutine yang melakukan operasi berat, menghindari oversubscribe CPU.
  • Lock contention: Pendekatan eksklusif memaksa koordinasi lebih eksplisit sehingga deadlock atau contention bisa dilacak dengan mudah.
  • Latency deterministik: Karena worker pool membatasi konversi request-per-worker, latency lebih stabil meski throughput menurun.

Penerapan tipikal:

type Task struct {
    ctx *fiber.Ctx
    done chan struct{}
}

func worker(tasks <-chan Task) {
    for t := range tasks {
        // proses blocking
        time.Sleep(200 * time.Millisecond)
        t.ctx.SendString("selesai")
        close(t.done)
    }
}

func main() {
    tasks := make(chan Task, 50)
    for i := 0; i < 10; i++ {
        go worker(tasks)
    }

    app := fiber.New()
    app.Get("/blocking", func(c *fiber.Ctx) error {
        t := Task{ctx: c, done: make(chan struct{})}
        tasks <- t
        <-t.done
        return nil
    })
    app.Listen(":3000")
}

Model ini menambahkan latency karena request harus menunggu worker, namun menjaga kestabilan CPU dan memudahkan pemantauan queue panjang lewat metrics seperti pending tasks.

Fitur Go Fiber (prefork, fasthttp) dan Dampaknya

Fiber menyediakan prefork untuk memanfaatkan beberapa core CPU dengan mem-fork proses worker. Ini sangat sinergis dengan model event-driven: setiap proses prefork mengelola loop fasthttp-nya sendiri sehingga goroutine tidak perlu dibatasi di level aplikasi. Namun, prefork menambah kompleksitas deploy dan observabilitas karena Anda menjalankan beberapa proses identik.

Pertimbangkan prefork ketika:

  • Workload bersifat I/O ringan namun Anda butuh pemanfaatan maximal seluruh core.
  • Profiling per proses tidak masalah, dengan log terpisah atau agen observabilitas (misalnya Prometheus exporter per proses).

Model worker pool bisa bekerja baik di prefork karena setiap proses tetap mengatur pool internalnya. Pastikan pula fasthttp (yang digunakan Fiber) tetap mendapat benefit ringan dari event-driven, sehingga operasi blocking dibatasi di handler.

Monitoring, Infrastruktur, dan Biaya Operasional

Untuk monitoring, pisahkan metrik untuk kedua arsitektur:

  • Event-driven: pantau jumlah goroutine, latency via middleware Fiber, serta GC pauses dan pollen count.
  • Thread pool: tambahkan metrik antrean (pending tasks), worker utilization, dan waktu tunggu slot.

Gunakan observabilitas yang sama (OpenTelemetry, Prometheus). Pastikan log meliputi ID request dan latency, karena debugging event-driven sering memerlukan tracing asinkron. Prefork menuntut log/metrics per proses—gunakan port dynamic atau agregator sidecar.

Dari sisi infrastruktur, worker pool mungkin membutuhkan lebih banyak memory karena queue buffering. Event-driven bisa menghasilkan banyak goroutine, yang artinya GC overhead lebih tinggi saat beban pik. Pantau juga memory footprint per proses Fiber dengan pprof dan setelan GC (misalnya GOGC).

Strategi Migrasi dan Coexistence untuk Skala Jangka Panjang

Strategi migrasi tipikal:

  1. Identifikasi handler paling berat (profil CPU dan latency).
  2. Terapkan worker pool untuk subset tersebut, sementara handler lain tetap event-driven.
  3. Gunakan feature flags untuk membiarkan tim mengalihkan traffic ke worker pool secara bertahap.

Coexistence dimungkinkan dengan membagi routing: gunakan router Fiber untuk menentukan jalur mana yang menggunakan worker pool. Misalnya, handler /async tetap event-driven, sedangkan /billing memakai pool agar operasi blocking tidak memengaruhi throughput global.

Ketika tim tumbuh, dokumentasikan pola yang digunakan, buat library internal (misalnya internal/pool) untuk memudahkan peninjauan code review, dan sertakan boilerplate monitoring agar tidak ada tim baru yang lupa memantau antrean atau goroutine.

Kesimpulannya, event-driven tetap menjadi default untuk Go Fiber karena keselarasan dengan goroutine ringan dan fasthttp. Namun, worker pool per request memberikan kontrol dan stabilitas untuk operasi blocking. Gabungkan keduanya dengan prefork dan observabilitas yang tepat, serta migrasi bertahap, untuk memastikan aplikasi tetap skalabel, dapat diobservasi, dan mudah dipelihara saat tim terus berkembang.