# Sinkronisasi Outbox Offline-First Flutter untuk Aplikasi Lapangan

> Bagaimana aplikasi lapangan Flutter JID Carwash tetap menerima order di area tanpa sinyal memakai outbox lokal, connectivity_plus, dan replay idempoten ke API NestJS.

URL: https://www.matthewswong.com/id/blog/flutter-offline-outbox-sync-field-app/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-07-14
- Updated: 2026-07-14
- Category: Software Development

> **Key takeaway:** Aplikasi lapangan offline-first mengantrekan setiap penulisan ke outbox SQLite lokal alih-alih langsung memanggil API. connectivity_plus memantau jaringan, sebuah worker latar belakang menguras outbox saat sinyal kembali, dan idempotency key buatan klien pada tiap baris menjamin API NestJS menerapkan setiap order tepat satu kali walau terjadi percobaan ulang.

JID Carwash beroperasi di area cuci beton tempat sinyal seluler mati begitu mobil masuk ke bawah atap. Aplikasi lapangan Flutter yang tim saya rilis, dibangun di atas Flutter 3.22 dan Dart 3.4 dengan Riverpod dan go_router, tidak bisa berasumsi jaringan tersedia ketika kru menekan tombol untuk membuka order. Jika aplikasi memblokir pada panggilan HTTP, seluruh bisnis akan tersendat di balik spinner. Maka setiap penulisan bersifat offline-first: UI mencatat niat secara lokal, dan jaringan menjadi detail implementasi yang terjadi belakangan.

Artikel ini membahas pola outbox di balik perilaku itu: bagaimana penulisan disimpan secara lokal, bagaimana connectivity_plus memicu penggurasan, bagaimana API NestJS memutar ulang secara idempoten, dan mengapa setiap fitur di aplikasi ini memiliki dua repository yang berbagi satu kontrak.

## Outbox adalah sumber kebenaran, bukan server

Ide intinya adalah jangan pernah membiarkan UI memanggil API. Sebagai gantinya, aksi seperti membuka order atau mencatat pembayaran menulis satu baris ke tabel outbox SQLite lokal. Baris itu membawa jenis operasi, payload JSON, pengenal buatan klien, status, penghitung percobaan, dan stempel waktu dibuat. Layar langsung membaca kembali penulisan lokalnya sendiri, sehingga kru melihat order muncul terlepas dari apakah satu byte pernah meninggalkan ponsel. Menguras outbox itu ke server adalah urusan asinkron yang terpisah.

Uang disimpan sebagai bilangan bulat rupiah IDR di mana-mana, tidak pernah sebagai nilai floating-point. Cucian seharga lima puluh ribu rupiah adalah bilangan bulat 50000. Ini penting berganda saat offline: payload yang mengendap di outbox selama dua puluh menit harus terdeserialisasi ke jumlah yang persis sama dengan yang ditagihkan server, dan bilangan bulat bertahan melewati perjalanan JSON tanpa penyimpangan pembulatan yang diperkenalkan float.

## connectivity_plus memantau, worker latar belakang menguras

connectivity_plus, saat ini di versi 7.3.0, menyediakan stream yang memancarkan setiap kali jenis jaringan yang terjangkau berubah. Pada versi mayor terbaru stream itu membawa daftar hasil alih-alih satu nilai, karena ponsel bisa berada di Wi-Fi dan seluler sekaligus. Saya memperlakukan setiap entri non-none sebagai sinyal untuk mencoba menguras. Namun stream itu hanya pemicu, bukan otoritas. Konektivitas yang melaporkan sebuah transport tidak membuktikan API terjangkau, jadi drainer tetap harus menoleransi kegagalan pada panggilan HTTP yang sebenarnya.

Penggurasan juga terpicu saat aplikasi dilanjutkan dan pada timer periodik, sehingga ponsel yang ditutup paksa di tengah shift tetap menyusul. Setiap percobaan menaikkan penghitung baris dan, saat gagal, menetapkan stempel waktu percobaan berikutnya memakai exponential backoff dengan jitter. Jitter itu disengaja: lima belas ponsel kru yang menyambung ulang ke Wi-Fi depot saat tutup toko jika tidak akan menyerbu API di detik yang sama. Setelah jumlah percobaan yang dibatasi, baris berpindah ke status gagal dan muncul di layar admin alih-alih mencoba selamanya.

> Sebuah event konektivitas bukan bukti server berfungsi. Perlakukan stream connectivity_plus sebagai petunjuk untuk mencoba, dan biarkan hasil HTTP yang menentukan apakah sebuah baris selesai atau harus mengulang. Menandai baris selesai hanya karena ponsel melaporkan Wi-Fi adalah cara Anda kehilangan order secara diam-diam.

## Replay idempoten: tepat-satu-kali tanpa sihir terdistribusi

Kegagalan offline klasik adalah permintaan yang berhasil di server tetapi responsnya tidak pernah sampai ke ponsel. Aplikasi habis waktu, mengulang, dan kini order yang sama ada dua kali. Perbaikannya adalah idempotency key yang dibuat di perangkat pada saat baris outbox dibuat. Karena kunci itu adalah pengenal baris itu sendiri, ia stabil di setiap percobaan ulang baris tersebut. Drainer Flutter mengirimnya sebagai header; layanan settlement NestJS mencatatnya dan memotong setiap pengulangan.

Di sisi server, settlement pembayaran berada di layanan khusus yang menyimpan setiap idempotency key beserta hasil penerapan sukses pertama. Permintaan kedua yang membawa kunci yang sudah pernah dilihat mengembalikan hasil tersimpan itu alih-alih melakukan settlement lagi. Ini jaminan yang sama yang dipakai jaringan kartu, diimplementasikan dengan satu kolom terindeks dan tanpa konsensus terdistribusi.

> Idempotency key dan pengenal baris lokal adalah UUID yang sama. Satu nilai menjalankan dua tugas: ia adalah primary key yang membuat layar membaca kembali penulisannya sendiri secara instan, dan ia adalah token yang membuat replay di sisi server aman. Lebih sedikit pengenal berarti lebih sedikit cara untuk salah menerapkan tepat-satu-kali.

## Urutan dan konflik: penulisan siapa yang menang

Menguras dalam urutan created-at yang ketat menjaga penulisan yang terkait secara kausal tetap benar. Sebuah order harus dibuat sebelum pembayarannya di-settle, jadi jika keduanya ada di outbox mereka diputar ulang dalam urutan yang dilakukan kru. Ketika satu baris gagal dan memblokir antrean, saya tidak melompati sebuah dependensi; operasi yang bergantung menunggu di belakangnya alih-alih men-settle pembayaran atas order yang belum pernah dilihat server.

Penanganan konflik sengaja dibuat sempit karena domainnya memudahkannya. Sebagian besar penulisan adalah penambahan yang tidak disentuh orang lain: order baru, pembayaran baru, kasbon uang muka karyawan. Itu tidak bisa berkonflik, jadi mereka diputar ulang tanpa syarat. Sedikit field yang dapat berubah membawa versi yang ditetapkan server, dan API menolak penulisan usang sehingga aplikasi bisa mengambil ulang dan membiarkan manusia memutuskan. Sebagian state server bahkan tidak disimpan, yang menghapus seluruh kategori konflik sekaligus.

Dua invarian server mengecilkan permukaan konflik lebih jauh. Buku kas adalah tampilan gabungan turunan yang dihitung saat baca, tidak pernah tabel yang ditulis, sehingga tidak ada dua ponsel yang bisa berselisih tentang barisnya. Dan kuantitas stok produk hanyalah cache; angka sebenarnya adalah jumlah delta pergerakan stok. Penulisan lapangan menambahkan sebuah pergerakan alih-alih menimpa hitungan, yang berarti dua kru yang menghabiskan stok sabun yang sama menghasilkan dua pergerakan aditif, bukan pembaruan yang hilang.

## Mengapa setiap fitur memiliki repository Api dan Mock

Setiap fitur mengekspos antarmuka repository abstrak dengan dua implementasi: sebuah ApiRepository yang berbicara ke NestJS melalui outbox, dan sebuah MockRepository yang sepenuhnya didukung fixture lokal. Riverpod memilih mana yang disuntikkan. Ini bukan sekadar kemudahan pengujian. Mock adalah mode runtime kelas satu, sehingga demo di calon lokasi waralaba menjalankan aplikasi penuh tanpa backend, dan QA bisa menelusuri setiap layar di dalam pesawat. Karena kedua implementasi memenuhi kontrak yang sama, perilaku offline bukan cabang khusus yang ditempelkan ke jalur langsung; ia sederajat dengannya.

Admin web POS mencerminkan ini persis dengan httpClient dan mockClient yang berbagi satu file types, yang berarti kedua permukaan sepakat tentang bentuk order sebelum satu permintaan pun dikirim. Menjaga kontrak di satu tempat itulah yang membuat aplikasi offline dan admin online tetap jujur satu sama lain.

## Ringkasan trade-off

| Perhatian | Naif hanya-online | Outbox offline-first |
| --- | --- | --- |
| Area tanpa sinyal | Terblokir di spinner | Merekam lokal, menguras nanti |
| Ulang setelah timeout | Berisiko order ganda | Idempotency key mendedup |
| Urutan penulisan | Apa pun yang tiba dulu | Replay created-at ketat |
| Demo tanpa backend | Mustahil | MockRepository jalankan aplikasi penuh |

Hasilnya adalah aplikasi yang memperlakukan jaringan sebagai tidak andal secara bawaan alih-alih sebagai jalur mulus dengan penanganan error yang ditempelkan. Kru membuka, mencuci, dan men-settle di zona mati sepanjang shift, dan order tiba di API NestJS secara utuh, berurutan, dan tepat satu kali, kapan pun sinyal memutuskan untuk kembali.

## Sources

- connectivity_plus — Flutter package (pub.dev)
- The Hidden Problems of Offline-First Sync: Idempotency, Retry Storms, Dead Letters (DEV)
- Offline-First Done Right: Sync Patterns for Real-World Mobile Networks

## Source links

- https://pub.dev/packages/connectivity_plus
- https://dev.to/salazarismo/the-hidden-problems-of-offline-first-sync-idempotency-retry-storms-and-dead-letters-1no8
- https://developersvoice.com/blog/mobile/offline-first-sync-patterns/

## FAQ

### Apa itu pola outbox dalam aplikasi Flutter offline-first?

Pola outbox menyimpan setiap penulisan, seperti membuka order atau mencatat pembayaran, sebagai baris di tabel SQLite lokal alih-alih langsung memanggil API. UI membaca kembali penulisan lokalnya sendiri secara instan, dan proses latar belakang terpisah menguras outbox ke server saat konektivitas kembali. Ini membuat aplikasi tetap berjalan tanpa sinyal sama sekali.

### Bagaimana connectivity_plus memicu sinkronisasi latar belakang?

connectivity_plus menyediakan stream yang memancarkan setiap kali jenis jaringan yang terjangkau berubah, dan pada versi terbaru ia memancarkan daftar hasil karena perangkat bisa memakai beberapa transport sekaligus. Aplikasi berlangganan stream itu dan mencoba menguras outbox pada setiap event non-none. Stream itu hanya petunjuk, jadi hasil HTTP sebenarnya tetap menentukan apakah tiap baris selesai atau harus mengulang.

### Bagaimana replay idempoten mencegah order ganda?

Setiap baris outbox dibuat dengan UUID buatan klien yang sekaligus menjadi idempotency key dan tetap stabil di setiap percobaan ulang. Aplikasi Flutter mengirimnya sebagai header, dan layanan settlement NestJS menyimpan kunci itu beserta hasil penerapan sukses pertama. Setiap permintaan ulang yang membawa kunci yang sudah pernah dilihat mengembalikan hasil tersimpan alih-alih men-settle lagi, sehingga percobaan ulang yang habis waktu tidak akan pernah membuat order kedua.

### Bagaimana urutan dan konflik ditangani saat sinkronisasi?

Outbox dikuras dalam urutan created-at yang ketat sehingga penulisan yang terkait secara kausal, seperti order sebelum pembayarannya, diputar ulang dalam urutan yang dilakukan kru. Sebagian besar penulisan adalah penambahan yang tidak bisa berkonflik, dan sedikit field yang dapat berubah membawa versi server yang menolak penulisan usang. Invarian server seperti buku kas turunan dan stok yang dilacak sebagai pergerakan aditif menghapus seluruh kategori konflik.

### Mengapa setiap fitur memiliki ApiRepository dan MockRepository sekaligus?

Setiap fitur mendefinisikan satu kontrak repository abstrak dengan dua implementasi: ApiRepository yang berbicara ke backend NestJS melalui outbox, dan MockRepository yang didukung fixture lokal. Riverpod menyuntikkan mana pun yang dibutuhkan, sehingga aplikasi bisa menjalankan demo penuh tanpa backend dan QA bisa menguji setiap layar secara offline. Berbagi satu kontrak menjaga perilaku offline sederajat dengan jalur langsung alih-alih sekadar kasus khusus yang ditempelkan.

---

- HTML version: https://www.matthewswong.com/id/blog/flutter-offline-outbox-sync-field-app/
- English: https://www.matthewswong.com/en/blog/flutter-offline-outbox-sync-field-app/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
