Belajar Golang dari Nol #17: Context, Timeout, dan Pembatalan
Di Belajar Golang dari Nol #16 kita merapikan project jadi tiga lapis: handler, service, repository. Alurnya sudah jelas. Request masuk lewat handler, logika dikerjakan service, data diambil repository.
Tapi ada satu hal yang belum kita urus. Semua fungsi di tiga lapis itu masih berjalan tanpa rem. Sekali query jalan, dia jalan terus sampai selesai. Tidak peduli siapa pun yang menunggunya sudah pergi.
Bagian ini membahas remnya. Namanya context.
Masalah yang Baru Kelihatan Saat Aplikasi Ramai
Bayangkan endpoint laporan penjualan di aplikasi Anda. Query-nya berat, butuh 8 detik. Pengguna klik menu laporan, menunggu 3 detik, lalu bosan dan menutup tab browser.
Apa yang terjadi di server? Query itu tetap jalan. Lima detik sisanya database tetap bekerja keras untuk hasil yang tidak akan pernah dibaca siapa pun. Koneksi database tetap dipegang. Memori tetap terpakai.
Satu pengguna tidak masalah. Tapi kalau 50 orang melakukan hal yang sama dalam satu menit, server Anda sibuk mengerjakan pekerjaan hantu.
Kasus kedua lebih menyakitkan. Aplikasi Anda memanggil API pihak ketiga, misalnya cek ongkir atau payment gateway. Server mereka sedang bermasalah dan tidak menjawab apa-apa. Bukan error, hanya diam. Koneksi Anda menggantung. Satu per satu request menumpuk sampai aplikasi berhenti melayani siapa pun.
Go menyediakan satu mekanisme untuk dua masalah ini. Context adalah cara Go menyampaikan pesan “sudah tidak usah dilanjut” ke semua bagian program yang sedang mengerjakan satu permintaan.
Analogi: Pesan Estafet ke Semua Petugas
Anggap satu request HTTP itu satu pesanan di dapur restoran. Pesanan masuk, lalu dibagi ke banyak orang. Ada yang menyiapkan nasi, ada yang menggoreng ayam, ada yang menyeduh minuman.
Context adalah selembar catatan kecil yang ikut ke setiap orang itu. Isinya dua hal: kabar pembatalan dan batas waktu.
Kalau pelanggan tiba-tiba pergi, pelayan menandai catatan itu sebagai batal. Semua orang yang memegang salinan catatan langsung tahu dan berhenti memasak. Kalau ada aturan “pesanan harus jadi dalam 10 menit”, batas itu juga tertulis di catatan yang sama.
Yang penting dipahami: context tidak memaksa siapa pun berhenti. Dia hanya menyampaikan kabar. Kode Andalah yang harus rajin mengecek catatan itu dan berhenti dengan sukarela.
context.Background dan context.TODO
Setiap context punya induk. Di ujung paling atas ada context kosong yang tidak pernah dibatalkan dan tidak punya batas waktu. Ada dua pilihan untuk itu.
context.Background()dipakai di titik paling awal program. Di dalammain(), saat inisialisasi, atau di test. Ini pilihan default Anda.context.TODO()dipakai saat Anda belum tahu context yang benar harus datang dari mana. Fungsinya sama persis, bedanya cuma penanda untuk diri sendiri bahwa bagian ini masih perlu dirapikan.
Aturan praktisnya sederhana. Kalau ragu, pakai context.Background(). Pakai context.TODO() hanya saat Anda sedang menambal kode lama dan belum sempat mengalirkan context dari atas.
context.WithTimeout: Memberi Batas Waktu
Ini fungsi yang paling sering Anda pakai. Coba jalankan kode berikut.
package main
import (
"context"
"fmt"
"time"
)
func ambilDataLambat(ctx context.Context) (string, error) {
hasil := make(chan string, 1)
go func() {
time.Sleep(5 * time.Second)
hasil <- "data dari server tetangga"
}()
select {
case data := <-hasil:
return data, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
mulai := time.Now()
data, err := ambilDataLambat(ctx)
if err != nil {
fmt.Println("gagal setelah", time.Since(mulai).Round(time.Second), ":", err)
return
}
fmt.Println("berhasil:", data)
}
Outputnya:
gagal setelah 2s : context deadline exceeded
Fungsinya butuh 5 detik, batas kita 2 detik. Setelah 2 detik, channel ctx.Done() tertutup dan select memilih cabang itu. Kita tidak menunggu 3 detik sisanya.
Dua hal yang perlu Anda ingat dari kode di atas:
ctx.Done()adalah channel. Selama context masih hidup, channel ini diam. Begitu batas waktu lewat atau ada pembatalan, channel ini tertutup dan semua yang menunggu langsung jalan.ctx.Err()memberi tahu alasannya. Nilainyacontext.DeadlineExceededkalau kehabisan waktu, ataucontext.Canceledkalau dibatalkan manual.
Kenapa defer cancel() Wajib
context.WithTimeout mengembalikan dua nilai. Yang kedua adalah fungsi cancel. Banyak pemula mengabaikannya karena kodenya tetap jalan tanpa itu. Ini kebiasaan yang mahal.
Saat Anda memanggil WithTimeout, Go diam-diam membuat timer di belakang layar dan mendaftarkan context baru ke induknya. Selama cancel belum dipanggil, timer dan pendaftaran itu masih nyangkut di memori.
Kalau fungsi Anda selesai dalam 100 milidetik tapi timeout-nya 5 detik, tanpa cancel ada sampah yang menganggur 4,9 detik. Di endpoint yang dipanggil ribuan kali per menit, sampah ini menumpuk. Itu yang disebut kebocoran resource.
Memanggil cancel dua kali aman, dan memanggilnya setelah timeout lewat juga aman. Jadi tidak ada alasan untuk melewatkannya. Tulis defer cancel() tepat di baris setelah WithTimeout, selalu.
context.WithCancel: Pembatalan Manual
Kadang yang Anda butuhkan bukan batas waktu, tapi tombol stop. Di bagian 10 kita membuat goroutine pekerja yang jalan terus. Waktu itu kita belum punya cara rapi untuk menghentikannya. Sekarang punya.
package main
import (
"context"
"fmt"
"time"
)
func pekerja(ctx context.Context, nama string) {
for {
select {
case <-ctx.Done():
fmt.Println(nama, "berhenti karena", ctx.Err())
return
default:
fmt.Println(nama, "sedang memproses antrean")
time.Sleep(700 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go pekerja(ctx, "pekerja-1")
go pekerja(ctx, "pekerja-2")
time.Sleep(2 * time.Second)
fmt.Println("perintah berhenti dikirim")
cancel()
time.Sleep(300 * time.Millisecond)
fmt.Println("aplikasi ditutup")
}
Contoh output:
pekerja-1 sedang memproses antrean
pekerja-2 sedang memproses antrean
pekerja-1 sedang memproses antrean
pekerja-2 sedang memproses antrean
pekerja-1 sedang memproses antrean
pekerja-2 sedang memproses antrean
perintah berhenti dikirim
pekerja-1 berhenti karena context canceled
pekerja-2 berhenti karena context canceled
aplikasi ditutup
Satu kali panggil cancel(), dua goroutine berhenti. Ini bedanya dengan cara lama memakai channel stop buatan sendiri. Context menyebar ke bawah secara otomatis, jadi cucu dari context ini ikut dibatalkan tanpa Anda urus satu per satu.
Context di HTTP Handler
Kabar baiknya, untuk aplikasi web Anda tidak perlu membuat context dari nol. Paket net/http sudah menyiapkannya di setiap request.
r.Context() mengembalikan context yang otomatis dibatalkan ketika klien memutus koneksi. Tutup tab, tekan tombol batal, atau koneksi internet putus, semuanya memicu pembatalan yang sama.
func laporanHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
log.Println("klien pergi di langkah", i, "alasan:", ctx.Err())
return
case <-time.After(1 * time.Second):
log.Println("selesai memproses langkah", i)
}
}
w.Write([]byte("laporan selesai"))
}
Jalankan server, panggil endpoint itu dengan curl, lalu tekan Ctrl+C sebelum 5 detik. Di log server Anda akan lihat prosesnya berhenti di tengah jalan. Tanpa pengecekan ctx.Done(), server tetap menghitung sampai langkah kelima untuk pengguna yang sudah tidak ada.
Mengalirkan Context ke Database
Di bagian 12 kita memakai db.Query dan db.Exec. Keduanya punya versi yang menerima context: QueryContext dan ExecContext.
Perubahannya kecil. Tambah parameter ctx di depan, ganti nama method. Begini repository produk kita setelah dirapikan.
package repository
import (
"context"
"database/sql"
"nama-project/internal/entity"
)
type ProdukRepository struct {
db *sql.DB
}
func NewProdukRepository(db *sql.DB) *ProdukRepository {
return &ProdukRepository{db: db}
}
func (r *ProdukRepository) FindAll(ctx context.Context) ([]entity.Produk, error) {
query := "SELECT id, nama, harga FROM produk ORDER BY id"
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var daftar []entity.Produk
for rows.Next() {
var p entity.Produk
if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
return nil, err
}
daftar = append(daftar, p)
}
return daftar, rows.Err()
}
func (r *ProdukRepository) Create(ctx context.Context, p entity.Produk) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO produk (nama, harga) VALUES (?, ?)",
p.Nama, p.Harga,
)
return err
}
Efeknya nyata, bukan sekadar kosmetik. Driver database seperti MySQL dan PostgreSQL akan mengirim sinyal pembatalan ke server database saat context mati. Query yang sedang berjalan benar-benar dihentikan di sisi database, lalu koneksi dikembalikan ke pool.
Tanpa ini, koneksi baru bebas setelah query selesai dengan caranya sendiri.
Aturan Idiomatik yang Perlu Dihafal
Tim Go menetapkan beberapa konvensi. Ikuti saja, karena seluruh ekosistem library Go mengikutinya.
- Context selalu parameter pertama, namanya ctx. Tulis
func Ambil(ctx context.Context, id int) error, bukan sebaliknya. Ini bukan selera, ini standar yang bikin kode Anda terbaca oleh siapa pun. - Jangan simpan context di dalam struct. Context itu milik satu request, sementara struct repository hidup selama aplikasi jalan. Menyimpannya di struct berarti satu request bisa membatalkan request orang lain. Oper lewat parameter.
- Jangan kirim nil. Kalau benar benar belum tahu mau isi apa, pakai
context.TODO(). Mengirimnilakan bikin panic saat ada yang memanggilctx.Done(). - Context aman dipakai banyak goroutine sekaligus. Anda boleh mengoper satu ctx yang sama ke lima goroutine tanpa mutex.
Sekilas tentang context.WithValue
Context juga bisa membawa data. Bentuknya seperti ini.
type kunciRequestID struct{}
ctx = context.WithValue(ctx, kunciRequestID{}, "req-8842")
if id, ok := ctx.Value(kunciRequestID{}).(string); ok {
log.Println("request id:", id)
}
Pakai ini secukupnya saja. WithValue ditujukan untuk data yang menempel pada satu request dan menembus banyak lapisan tanpa jadi urusan bisnis, misalnya request ID, trace ID, atau identitas user hasil middleware autentikasi dari bagian 13.
Peringatannya: jangan pakai WithValue untuk mengoper parameter fungsi. Kalau service Anda butuh produkID, jadikan itu parameter biasa. Menaruhnya di context bikin signature fungsi berbohong, tipe datanya tidak dicek compiler, dan orang berikutnya harus membaca seluruh isi project untuk tahu apa yang ada di dalam ctx.
Satu catatan teknis: pakai tipe kunci buatan sendiri seperti kunciRequestID di atas, jangan string biasa, supaya kunci Anda tidak bentrok dengan kunci milik library lain.
Latihan: Endpoint Produk dari Ujung ke Ujung
Sekarang kita gabungkan semuanya. Target kita: request masuk ke handler, context-nya mengalir ke service, service memberi batas 3 detik, lalu diteruskan ke query database.
Lapisan service:
package service
import (
"context"
"time"
"nama-project/internal/entity"
"nama-project/internal/repository"
)
type ProdukService struct {
repo *repository.ProdukRepository
}
func NewProdukService(repo *repository.ProdukRepository) *ProdukService {
return &ProdukService{repo: repo}
}
func (s *ProdukService) ListProduk(ctx context.Context) ([]entity.Produk, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return s.repo.FindAll(ctx)
}
Perhatikan baris context.WithTimeout(ctx, ...). Induknya adalah ctx dari request, bukan context.Background(). Jadi ada dua pemicu berhenti sekaligus: klien pergi, atau waktu 3 detik habis. Mana pun yang lebih dulu, query berhenti.
Lapisan handler:
package handler
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"nama-project/internal/service"
)
type ProdukHandler struct {
service *service.ProdukService
}
func NewProdukHandler(s *service.ProdukService) *ProdukHandler {
return &ProdukHandler{service: s}
}
func (h *ProdukHandler) List(w http.ResponseWriter, r *http.Request) {
daftar, err := h.service.ListProduk(r.Context())
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
log.Println("query produk melebihi batas waktu")
http.Error(w, "permintaan terlalu lama diproses", http.StatusGatewayTimeout)
case errors.Is(err, context.Canceled):
log.Println("klien membatalkan permintaan produk")
default:
log.Println("gagal mengambil produk:", err)
http.Error(w, "terjadi kesalahan di server", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(daftar)
}
Saat context batal karena klien pergi, kita hanya menulis log tanpa mengirim response. Wajar, karena tidak ada lagi yang menerimanya.
Menguji Kondisi Normal
$ curl -i http://localhost:8080/api/produk
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"nama":"Kopi Gayo 250g","harga":85000},
{"id":2,"nama":"Kopi Toraja 250g","harga":92000}]
Cepat dan tidak ada yang aneh. Timeout 3 detik tidak pernah tersentuh.
Menguji Kondisi Timeout
Untuk membuktikan timeout bekerja, sisipkan query lambat sementara di FindAll. Di MySQL pakai SELECT SLEEP(6), di PostgreSQL pakai SELECT pg_sleep(6).
query := "SELECT SLEEP(6)"
Lalu panggil lagi endpointnya.
$ curl -i -w "\nwaktu total: %{time_total}s\n" http://localhost:8080/api/produk
HTTP/1.1 504 Gateway Timeout
Content-Type: text/plain; charset=utf-8
permintaan terlalu lama diproses
waktu total: 3.012s
Log servernya:
2026/07/26 10:14:22 query produk melebihi batas waktu
Tiga detik, bukan enam. Pengguna dapat jawaban yang jelas, koneksi database kembali ke pool, dan server Anda tidak menahan pekerjaan yang tidak berguna. Setelah selesai mencoba, kembalikan query aslinya.
Rangkuman
Context itu kontrak kecil dengan efek besar. Isinya cuma kabar batal dan batas waktu, tapi dia yang menjaga aplikasi Anda tetap waras saat trafik naik.
Tiga hal yang layak Anda bawa pulang. Selalu tulis defer cancel(). Selalu taruh ctx sebagai parameter pertama. Selalu alirkan context sampai ke query database, jangan berhenti di service.
Kalau Anda sudah sampai sini, aplikasi Go Anda sudah punya struktur rapi dan kontrol waktu yang jelas. Yang belum, cara mencatat apa yang terjadi saat ada masalah. Itu bahasan bagian 18: Logging dan Error Handling yang Rapi di Aplikasi Go.
Kalau Anda butuh partner untuk membangun aplikasi backend yang siap dipakai banyak pengguna, tim kami siap bantu lewat layanan pengembangan sistem aplikasi.
Artikel Lainnya di Kategori Golang
Golang 6 Agustus 2026
Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http
Seri Go pemula sampai di gerbang backend: bikin REST API dengan net/http, routing method Go 1.22, JSON response, dan API produk mini lengkap.
Baca Artikel
Golang 1 Agustus 2026
Belajar Golang dari Nol #6: Perulangan for dan Percabangan
Lanjutan seri Go pemula: tiga bentuk for, for range tuntas, break dan continue, if dengan statement pendek, switch, plus latihan rekap penjualan.
Baca Artikel
Golang 2 Agustus 2026
Belajar Golang dari Nol #7: Pointer, Alamat Memori Tanpa Pusing
Pahami pointer Go lewat analogi fotokopi vs alamat rumah. Operator & dan *, pointer receiver, nil pointer, sampai latihan sistem saldo rekening.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel