Async Processing #
Ada satu keputusan desain yang membedakan sistem yang bertahan di skala besar dari sistem yang kolaps saat traffic naik: apakah setiap operasi harus selesai sebelum sistem bisa merespons, atau sebagian operasi boleh diselesaikan belakangan. Async processing adalah jawaban untuk pertanyaan itu — sebuah keputusan arsitektur, bukan sekadar optimasi performa. Sistem yang dirancang async dari awal bisa melepas ribuan pekerjaan ke background, membalas pengguna dalam milidetik, dan menyelesaikan pekerjaan beratnya tanpa menghambat siapapun. Panduan ini membahas async processing secara menyeluruh: definisi dan perbedaannya dari synchronous, tiga bentuk implementasi yang berbeda karakteristiknya, empat prinsip dasar, pola arsitektur di sistem modern, serta konsekuensi nyata yang harus diantisipasi sejak awal desain.
Apa Itu Async Processing? #
Async processing adalah pola pemrosesan di mana suatu task tidak dieksekusi secara blocking terhadap alur utama eksekusi program. Request tidak harus menunggu proses selesai — ia bisa langsung mendapat respons sementara pekerjaan berat dilanjutkan di background, oleh worker lain, atau di waktu yang berbeda.
Perbedaan paling mendasar dengan synchronous:
Synchronous (blocking):
Request ──→ [Proses A] ──→ [Proses B] ──→ [Proses C] ──→ Response
(50ms) (200ms) (300ms)
Total waktu tunggu user: 550ms
Asynchronous (non-blocking):
Request ──→ [Validasi] ──→ Antri ke queue ──→ Response (job_id)
(5ms) (2ms) Total: ~7ms
│
▼ (background, bisa detik/menit kemudian)
[Proses A] ──→ [Proses B] ──→ [Proses C]
(50ms) (200ms) (300ms)
Kata kunci yang membedakan: di model synchronous, user menunggu semua proses selesai. Di model async, user mendapat respons segera dan proses berjalan di luar jalur utama.
Perlu dicatat: async processing berbeda dari async/await di level kode. Async/await hanya membuat kode non-blocking di dalam satu proses. Async processing dalam arti arsitektur mencakup pemrosesan yang bisa berjalan di proses berbeda, mesin berbeda, bahkan di waktu yang jauh berbeda.
Mengapa Async Processing Dibutuhkan #
Async processing lahir dari keterbatasan yang sangat nyata pada sistem synchronous ketika berhadapan dengan beban yang besar.
Thread blocking adalah akar masalahnya. Di sistem synchronous, satu request mengunci satu thread dari awal sampai selesai. Jika proses melibatkan query database yang lambat, call ke API eksternal, atau komputasi berat, thread itu menganggur menunggu — tidak bisa melayani siapapun. Di traffic rendah ini tidak terasa. Di traffic tinggi, thread pool habis, request mulai antri, latency membengkak, dan akhirnya sistem tidak responsif.
Tidak semua proses harus selesai saat request berlangsung. Ini insight paling penting. Ketika user registrasi, apakah mereka benar-benar harus menunggu email konfirmasi terkirim sebelum mendapat halaman “Registrasi berhasil”? Ketika user upload foto profil, apakah mereka harus menunggu proses resize ke semua resolusi selesai? Hampir selalu jawabannya tidak. Operasi-operasi ini bisa — dan seharusnya — diselesaikan di background.
Beberapa operasi yang secara natural cocok untuk async:
Operasi Alasan Tidak Perlu Sync
────────────────────────────── ────────────────────────────────────────────
Kirim email / notifikasi User tidak perlu tahu kapan email sampai
Resize / compress gambar User butuh upload selesai, bukan resize selesai
Generate laporan PDF Bisa polling atau notifikasi saat siap
Sinkronisasi ke third-party Eventual consistency cukup
Payment settlement Gateway proses async, webhook akan datang
Audit log / analytics Tidak ada UI yang menunggu log ini
Indexing untuk search Search index bisa sedikit di belakang data asli
Empat Prinsip Dasar #
Sebelum memilih bentuk implementasi async, penting dipahami empat prinsip yang menjadi fondasi pola ini.
1. Decoupling #
Pemanggil tidak perlu tahu bagaimana dan kapan task selesai. Ia hanya perlu tahu bahwa task sudah diterima. Decoupling ini adalah yang membuat async scalable — producer dan consumer bisa berkembang secara independen.
// ANTI-PATTERN: tight coupling — caller tahu dan menunggu semua detail
func registerUser(user User) error {
if err := db.Save(user); err != nil {
return err
}
// Caller harus menunggu email terkirim — coupling yang tidak perlu
if err := emailService.SendWelcome(user.Email); err != nil {
return err // registrasi gagal hanya karena email gagal?
}
if err := analyticsService.Track("user_registered", user); err != nil {
return err
}
return nil
}
// BENAR: loose coupling — caller hanya publish event, tidak peduli siapa yang consume
func registerUser(user User) error {
if err := db.Save(user); err != nil {
return err
}
// Publish event — siapa yang subscribe dan kapan diproses bukan urusan ini
return eventBus.Publish("user.registered", UserRegisteredEvent{
UserID: user.ID,
Email: user.Email,
CreatedAt: time.Now(),
})
}
// EmailService, AnalyticsService, OnboardingService, dll subscribe sendiri
2. Non-Blocking Execution #
Thread utama tidak diblokir untuk menunggu task selesai. Ia mendelegasikan pekerjaan dan langsung bebas melayani request berikutnya. Ini yang memungkinkan satu instance server menangani ribuan concurrent request meski pekerjaan sebenarnya membutuhkan waktu lama.
3. Eventual Completion #
Task akan selesai pada akhirnya — tapi tidak harus sekarang. Ini menerima trade-off: sistem tidak memberikan immediate consistency tapi eventual consistency. Penting untuk memahami kapan trade-off ini acceptable dan kapan tidak. Saldo rekening yang ditampilkan ke user harus konsisten segera; analytics dashboard bisa tertinggal beberapa detik.
4. State Awareness #
Karena task berjalan terpisah dari request yang memicunya, state harus disimpan secara eksplisit. Jika worker crash di tengah jalan dan tidak ada state yang tersimpan, pekerjaan hilang. State management yang benar — menyimpan job ID, status, progress, hasil, dan error — adalah apa yang membedakan sistem async yang reliable dari yang fragile.
Tiga Bentuk Async Processing #
Tidak semua async processing sama. Ada tiga bentuk utama yang berbeda dalam scope, durabilitas, dan kompleksitasnya.
Bentuk 1 — Async di Level Kode #
Task berjalan dalam proses yang sama tapi tidak memblokir thread. Ini adalah bentuk paling ringan — tidak butuh infrastruktur tambahan, tidak butuh message broker.
// Go — goroutine untuk operasi non-kritis yang tidak perlu dikonfirmasi
func handleRegistration(w http.ResponseWriter, r *http.Request) {
user, err := parseAndValidateUser(r.Body)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
if err := db.SaveUser(user); err != nil {
http.Error(w, "Registration failed", 500)
return
}
// Kirim response segera — tidak tunggu email
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"user_id": user.ID})
// Email dikirim di background — tidak memblokir response
go func() {
if err := emailService.SendWelcome(user); err != nil {
log.Errorf("welcome email failed for user %s: %v", user.ID, err)
// ✓ Error dicatat, tapi tidak mempengaruhi registrasi user
}
}()
}
Async di level kode (goroutine, thread) tidak tahan crash. Jika process mati saat goroutine sedang berjalan, pekerjaan itu hilang. Gunakan bentuk ini hanya untuk operasi yang boleh gagal tanpa konsekuensi serius — seperti logging, analytics non-kritis, atau cache warming. Untuk operasi yang harus selesai (email konfirmasi, payment), gunakan message queue yang durable.
Bentuk 2 — Background Worker dengan Message Queue #
Task dikirim ke antrian (queue) yang persisten, kemudian diambil dan diproses oleh worker yang berjalan terpisah. Ini adalah pola yang paling umum dan paling robust untuk async processing di production.
Flow dengan Message Queue:
HTTP Request
│
▼
API Server
│ publish job
▼
Message Queue (SQS / RabbitMQ / Kafka)
│
├─── Worker 1 ─── proses ─── update DB
├─── Worker 2 ─── proses ─── kirim email
└─── Worker 3 ─── proses ─── sync ke CRM
// Publisher — di HTTP handler
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
order, err := h.orderService.Create(r.Context(), parseOrderRequest(r))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Publikasikan job ke queue — bukan eksekusi langsung
job := ProcessOrderJob{
OrderID: order.ID,
UserID: order.UserID,
CreatedAt: time.Now(),
}
if err := h.queue.Publish("order.process", job); err != nil {
// Queue gagal — log tapi order sudah tersimpan
// Bisa juga pakai outbox pattern untuk garantee delivery
log.Errorf("failed to enqueue order %s: %v", order.ID, err)
}
// Respons langsung — tidak tunggu order diproses
w.WriteHeader(http.StatusAccepted) // 202: diterima, belum tentu selesai
json.NewEncoder(w).Encode(OrderResponse{
OrderID: order.ID,
Status: "processing",
})
}
// Consumer — worker terpisah
func (w *OrderWorker) Run(ctx context.Context) {
for {
msg, err := w.queue.Receive(ctx, "order.process")
if err != nil {
if ctx.Err() != nil {
return // shutdown gracefully
}
log.Errorf("receive error: %v", err)
continue
}
var job ProcessOrderJob
if err := json.Unmarshal(msg.Body, &job); err != nil {
log.Errorf("unmarshal job: %v", err)
msg.Nack() // kembalikan ke queue atau kirim ke DLQ
continue
}
if err := w.processOrder(ctx, job); err != nil {
log.Errorf("process order %s: %v", job.OrderID, err)
msg.Nack()
continue
}
msg.Ack() // ✓ tandai sebagai selesai
}
}
Keunggulan utama bentuk ini dibanding goroutine: durabilitas. Pesan tersimpan di broker — jika worker crash, pesan tidak hilang dan akan diproses ulang oleh worker lain atau setelah restart.
Bentuk 3 — Event-Driven Async Processing #
Alih-alih mengirim job ke satu worker, sistem menerbitkan event ke bus — dan banyak consumer yang tertarik bisa bereaksi secara independen dan bersamaan.
// Event publisher — tidak peduli siapa yang subscribe
func (s *UserService) CompleteRegistration(ctx context.Context, user User) error {
if err := s.repo.Save(ctx, user); err != nil {
return err
}
event := UserRegisteredEvent{
EventID: uuid.New().String(),
UserID: user.ID,
Email: user.Email,
Plan: user.Plan,
CreatedAt: time.Now(),
}
return s.eventBus.Publish(ctx, "user.registered", event)
// Siapa yang subscribe dan apa yang mereka lakukan bukan urusan service ini
}
// Consumer A — email service, subscribe sendiri
func (h *EmailHandler) OnUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
return h.mailer.Send(WelcomeEmail{To: event.Email, UserID: event.UserID})
}
// Consumer B — analytics service, subscribe sendiri
func (h *AnalyticsHandler) OnUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
return h.tracker.Track("user_registered", map[string]interface{}{
"user_id": event.UserID,
"plan": event.Plan,
})
}
// Consumer C — onboarding service, subscribe sendiri
func (h *OnboardingHandler) OnUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
return h.scheduler.ScheduleOnboardingSequence(event.UserID, event.Plan)
}
Pola event-driven memberikan loose coupling yang paling ekstrem: menambahkan consumer baru (misalnya Slack notification untuk tim sales) tidak perlu mengubah satu baris pun di UserService.
Async Processing di Arsitektur Modern #
Async processing adalah fondasi dari beberapa pola arsitektur yang paling umum digunakan hari ini.
Microservices sangat bergantung pada async communication untuk menghindari cascading failure. Jika Service A memanggil Service B secara synchronous dan Service B down, Service A ikut gagal. Jika komunikasinya async melalui message queue, Service A tetap bisa beroperasi — pesan terakumulasi di queue dan diproses saat Service B kembali online.
Synchronous microservice — cascading failure:
A ──sync──→ B ──sync──→ C (down)
│ │
└── A ikut gagal ←───────┘
Asynchronous microservice — isolated failure:
A ──async──→ Queue ──→ B ──async──→ Queue ──→ C (down)
│ │ │
A tetap OK pesan pesan
terakumulasi terakumulasi,
diproses saat C pulih
Serverless secara natural async — function dipicu oleh event (HTTP request, message queue, schedule, file upload) dan tidak ada yang “menunggu” function selesai kecuali dalam kasus tertentu. Model ini mendorong desain async secara struktural.
High-traffic system menggunakan queue sebagai buffer untuk meratakan lonjakan traffic. Saat traffic spike, request masuk ke queue lebih cepat dari kemampuan worker memproses — tapi worker memproses pada kecepatan yang stabil dan terkontrol, tidak kolaps karena overload.
Konsekuensi yang Harus Diantisipasi #
Async processing membawa manfaat nyata, tapi juga konsekuensi yang harus didesain sejak awal — bukan diperbaiki setelah terjadi masalah.
Eventual consistency adalah trade-off yang paling fundamental. Data tidak selalu konsisten secara instan di semua bagian sistem. Ini acceptable untuk banyak kasus (analytics, search index, email), tapi tidak untuk kasus lain (saldo rekening, inventory yang diperlihatkan ke user sebelum checkout). Engineer harus sadar betul batas mana yang aman untuk eventual dan mana yang butuh strong consistency.
Idempotency menjadi wajib karena worker bisa menerima message yang sama lebih dari sekali (retry, redelivery). Worker yang tidak idempotent akan menghasilkan data ganda, email ganda, atau charge ganda.
// ANTI-PATTERN: worker tidak idempotent
func (w *InvoiceWorker) GenerateInvoice(job InvoiceJob) error {
invoice := buildInvoice(job)
return db.Insert(invoice) // ← akan gagal atau duplicate jika job di-replay
}
// BENAR: worker idempotent — aman dijalankan berkali-kali
func (w *InvoiceWorker) GenerateInvoice(ctx context.Context, job InvoiceJob) error {
// Cek apakah invoice untuk order ini sudah pernah dibuat
existing, _ := db.FindInvoiceByOrderID(ctx, job.OrderID)
if existing != nil {
log.Infof("invoice already exists for order %s, skipping", job.OrderID)
return nil // ✓ aman di-skip
}
invoice := buildInvoice(job)
return db.Insert(ctx, invoice)
}
Debugging lebih kompleks karena alur tidak linear. Stack trace tidak lagi dari HTTP handler langsung ke error — error bisa terjadi di worker yang berjalan di proses berbeda, menit kemudian, tanpa koneksi jelas ke request yang memicunya. Tanpa correlation ID yang konsisten di seluruh sistem, debugging menjadi sangat sulit.
// BENAR: propagasi correlation ID dari request ke job ke worker
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
correlationID := r.Header.Get("X-Correlation-ID")
if correlationID == "" {
correlationID = uuid.New().String()
}
job := Job{
ID: uuid.New().String(),
CorrelationID: correlationID, // ← propagate ke job
Payload: parsePayload(r),
}
h.queue.Publish(job)
}
func (w *Worker) Process(job Job) error {
// Log dengan correlation ID — bisa di-trace dari request ke worker
logger := log.WithField("correlation_id", job.CorrelationID)
logger.Info("processing job")
// ...
}
Pola Umum: Outbox Pattern #
Salah satu masalah klasik async processing adalah dual write — bagaimana memastikan data tersimpan ke database DAN event terpublish ke queue secara atomic. Jika keduanya dilakukan secara terpisah, ada window di mana database berhasil tapi publish ke queue gagal (atau sebaliknya).
// ANTI-PATTERN: dual write — window kegagalan antara DB dan queue
func createOrder(order Order) error {
if err := db.Save(order); err != nil {
return err
}
// ← crash di sini: order tersimpan tapi event tidak pernah dikirim
return queue.Publish("order.created", order)
}
// BENAR: outbox pattern — simpan event ke DB dalam satu transaction
func createOrder(ctx context.Context, order Order) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
// Event disimpan ke tabel outbox dalam transaction yang sama
// Guaranteed: jika order tersimpan, outbox event juga tersimpan
outboxEvent := OutboxEvent{
ID: uuid.New().String(),
Topic: "order.created",
Payload: mustMarshal(order),
CreatedAt: time.Now(),
Sent: false,
}
return tx.Create(&outboxEvent).Error
})
// Outbox relay service secara periodik membaca tabel outbox
// dan mempublish event ke queue, lalu menandai sebagai sent
}
Checklist: Keputusan Async yang Tepat #
Sebelum memutuskan untuk membuat suatu operasi async, gunakan pertanyaan ini:
LAYAK ASYNC jika semua jawaban "ya":
□ Apakah user/caller bisa tetap melanjutkan tanpa menunggu hasil?
□ Apakah eventual consistency acceptable untuk operasi ini?
□ Apakah ada retry strategy jika worker gagal?
□ Apakah worker bisa dibuat idempotent?
□ Apakah ada cara untuk melacak status job jika dibutuhkan?
TETAP SYNC jika salah satu jawaban "ya":
□ Apakah hasil operasi langsung dibutuhkan untuk response?
□ Apakah operasi ini bagian dari validasi yang harus atomic?
□ Apakah kegagalan operasi ini harus langsung dikembalikan sebagai error?
□ Apakah operasi ini harus selesai dalam satu database transaction?
ASPEK YANG HARUS DIDESAIN SEJAK AWAL:
□ Bagaimana job ID dipropagasi untuk tracking?
□ Bagaimana correlation ID dipropagasi untuk debugging?
□ Apa yang terjadi jika worker gagal setelah N kali retry?
□ Apakah ada DLQ untuk event yang tidak bisa diproses?
□ Bagaimana operator tahu ada backlog yang menumpuk?
Anti-Pattern yang Harus Dihindari #
// ✗ Fire and forget tanpa error handling — kegagalan tidak terdeteksi
go func() {
processHeavyTask(data) // tidak ada log, tidak ada retry
}()
// ✓ Selalu log error dan pertimbangkan apakah perlu queue yang durable
// ✗ Async untuk operasi yang butuh hasil segera
jobID := queue.Publish(ValidatePaymentJob{...})
// langsung polling hasil — mengalahkan tujuan async itu sendiri
result := pollUntilDone(jobID)
// ✓ Jika hasil harus langsung ada, gunakan sync
// ✗ Tidak ada status tracking — user tidak bisa tahu progress
func uploadFile(w http.ResponseWriter, r *http.Request) {
queue.Publish(ProcessFileJob{...})
w.WriteHeader(202) // user tidak tahu apa yang terjadi selanjutnya
}
// ✓ Return job ID, sediakan endpoint /jobs/{id}/status
// ✗ Worker tanpa graceful shutdown — job hilang saat deploy
func main() {
for msg := range queue.Messages() {
processJob(msg) // ← deploy saat ini sedang proses = job hilang
}
}
// ✓ Handle OS signal, selesaikan job yang sedang berjalan sebelum exit
Ringkasan #
- Async processing adalah keputusan desain arsitektur, bukan sekadar optimasi — sistem besar dirancang async dari awal, bukan dikonversi belakangan.
- Tiga bentuk utama: async di level kode (goroutine/coroutine, tidak tahan crash), background worker dengan message queue (durable, scalable), dan event-driven (loose coupling, multi-consumer).
- Empat prinsip dasar: decoupling (caller tidak peduli bagaimana task selesai), non-blocking execution, eventual completion, dan state awareness.
- Message queue adalah tulang punggung async yang robust — pesan persisten, worker bisa crash dan restart tanpa kehilangan pekerjaan.
- Idempotency wajib di semua worker — message broker at-least-once delivery berarti message bisa datang lebih dari sekali.
- Outbox pattern menyelesaikan masalah dual write — simpan event ke database dalam satu transaction dengan data, relay ke queue secara terpisah.
- Correlation ID harus dipropagasi dari request ke job ke worker — tanpa ini, debugging sistem async sangat menyakitkan.
- Eventual consistency adalah trade-off yang harus disadari — acceptable untuk email, analytics, dan index; tidak acceptable untuk saldo dan inventory real-time.
- Async bukan untuk semua hal: validasi kritis, transaksi atomic, dan operasi yang hasilnya langsung dibutuhkan harus tetap synchronous.
← Sebelumnya: Reactive Programming Berikutnya: Event-Driven →