Kebanyakan project iOS yang kami buka masih punya layer networking berbasis completion handler. Closure bersarang tiga tingkat, weak self di setiap tingkat, dan minimal satu jalur error yang lupa memanggil callback. Padahal async/await sudah ada sejak Swift 5.5 di tahun 2021 dan jalan di iOS 13 ke atas. Jadi alasan "masih harus support device lama" sudah lama tidak berlaku. Yang sekarang bikin tim tersendat justru bagian lainnya: cancellation, actor, Sendable, dan pengecekan Swift 6 yang lebih ketat. Berikut urutan migrasi yang biasa kami pakai, plus kesalahan yang sering muncul saat code review.
Ganti Completion Handler dari Layer Paling Bawah
Mulai dari layer terbawah, biasanya API client. URLSession sudah punya method async seperti data(for:), jadi fungsi request bisa langsung jadi fungsi async throws yang mengembalikan model. Untuk API berbasis callback milik sendiri atau SDK yang tidak bisa diubah, bungkus dengan withCheckedThrowingContinuation. Versi checked ini akan crash kalau continuation di-resume dua kali dan memberi peringatan kalau tidak pernah di-resume. Bug seperti ini dulu gampang lolos di completion handler. Simpan dulu signature lama berbasis closure sebagai pembungkus tipis di atas versi async. Dengan begitu layar bisa dipindahkan satu per satu, tidak perlu satu pull request raksasa.
Task dan Cancellation
Setiap fungsi async berjalan di dalam sebuah task. Task { } mewarisi actor dan prioritas dari tempat ia dibuat. Task.detached tidak mewarisi apa pun, dan di kode aplikasi hampir tidak pernah dibutuhkan. Cancellation di Swift sifatnya kooperatif. Memanggil cancel() hanya menyalakan sebuah flag. URLSession dan Task.sleep memeriksa flag itu lalu melempar error, tapi loop buatan Anda sendiri akan jalan terus kecuali memanggil Task.checkCancellation() atau mengecek Task.isCancelled. Contoh paling umum adalah fitur pencarian saat mengetik. Simpan referensi ke task pencarian yang sedang jalan, batalkan saat pengguna mengetik lagi, lalu awali task baru dengan Task.sleep 300 ms. Karena sleep ikut melempar error saat dibatalkan, Anda dapat debounce tanpa kode tambahan, dan hasil request lama tidak akan menimpa hasil yang lebih baru.
Menjalankan Pekerjaan Secara Paralel
| Cara | Cocok dipakai saat | Hati-hati dengan |
|---|---|---|
| await berurutan | Panggilan kedua butuh hasil panggilan pertama | Tanpa sadar menjalankan berurutan panggilan yang sebenarnya bisa paralel |
| async let | Jumlah panggilan sedikit dan tetap, misalnya profil, saldo, dan notifikasi di halaman beranda | Child task dibatalkan kalau scope ditinggalkan sebelum di-await |
| withThrowingTaskGroup | Jumlah panggilan dinamis, misalnya upload 20 foto | Menembak ratusan request sekaligus. Batasi berapa yang jalan bersamaan |
| Task { } | Menjembatani dari kode sinkron seperti aksi tombol | Tidak ada yang memiliki atau membatalkannya kalau handle-nya tidak disimpan |
Sebisa mungkin pakai async let dan task group. Keduanya terstruktur: child task tidak bisa hidup lebih lama dari parent-nya, error naik ke atas, dan membatalkan parent otomatis membatalkan child. Halaman beranda yang tadinya memanggil tiga API berurutan masing-masing 300 ms bisa turun dari sekitar 900 ms menjadi kurang lebih selama panggilan yang paling lambat, cukup dengan pindah ke async let.
Actor dan @MainActor
Actor melindungi state yang bisa berubah dengan cara hanya mengizinkan satu pemanggil masuk dalam satu waktu. Cocok untuk cache gambar atau penyimpanan token. Jebakannya ada di reentrancy. Saat method di dalam actor bertemu await, pemanggil lain bisa masuk dulu sebelum method itu lanjut. Artinya, state yang dibaca sebelum await bisa saja sudah berubah sesudahnya. Contoh nyatanya refresh token. Kalau lima request kena 401 di saat bersamaan, actor yang ditulis seadanya akan melakukan refresh lima kali. Solusinya, simpan proses refresh yang sedang berjalan sebagai Task di dalam actor, lalu biarkan semua pemanggil menunggu task yang sama.
Tandai view model dan semua yang menyentuh UI dengan @MainActor. Sejak Swift 6.2, sebuah modul bisa memilih isolasi main actor sebagai default, dan project aplikasi baru di Xcode 26 sudah menyalakannya. Untuk target aplikasi kami suka pengaturan ini, karena isinya memang kebanyakan kode UI. Untuk modul networking, parsing, atau pengolahan gambar, kami biarkan mati dan memindahkan fungsi berat keluar dari main actor secara eksplisit dengan nonisolated, atau @concurrent di Swift 6.2 ke atas.
Sendable dan Pengecekan Ketat Swift 6
Sendable menandai tipe yang aman dikirim antar-domain concurrency. Struct dan enum yang isinya Sendable biasanya langsung memenuhi syarat. Class harus final dengan properti let yang tidak berubah, atau diubah jadi actor, atau ditandai @unchecked Sendable dengan locking yang sungguhan di baliknya, misalnya Mutex dari framework Synchronization di iOS 18 atau OSAllocatedUnfairLock di iOS 16. Mode bahasa Swift 6 mengubah peringatan data race menjadi error kompilasi. Di aplikasi berukuran sedang, menyalakan semuanya sekaligus bisa memunculkan ratusan warning, dan setelah lima puluh warning pertama biasanya tidak ada lagi yang membaca. Lebih aman jalan per modul.
- 1Update ke Xcode terbaru, tapi tetap di mode bahasa Swift 5 dulu.
- 2Pilih modul paling ujung yang dependensinya sedikit, misalnya models atau utilities, lalu set Strict Concurrency Checking ke Complete untuk modul itu saja.
- 3Bereskan warning-nya: jadikan model sebagai value type yang Sendable, ubah singleton yang menyimpan state bersama menjadi actor atau taruh di main actor.
- 4Pakai @preconcurrency import untuk library pihak ketiga yang belum punya anotasi Sendable, dan catat library mana saja supaya bisa dicabut nanti.
- 5Pindahkan modul itu ke mode bahasa Swift 6 supaya pelanggaran baru langsung bikin build gagal.
- 6Naik ke modul berikutnya sesuai urutan dependensi: networking, lalu modul fitur, dan target aplikasi paling akhir.
Modifier .task di SwiftUI
Di SwiftUI, muat data dengan modifier .task, jangan dengan onAppear yang di dalamnya ada Task. Modifier .task mulai saat view muncul dan otomatis dibatalkan saat view hilang. Dengan .task(id:), SwiftUI membatalkan task yang sedang jalan dan memulai yang baru setiap kali id berubah. Untuk kolom pencarian atau pilihan filter, ini sudah hampir cukup. Pull to refresh juga mirip lewat .refreshable, yang menerima closure async dan menahan spinner sampai prosesnya selesai.
Kesalahan yang Sering Kami Temukan Saat Code Review
- Menaburkan Task { } di mana-mana supaya compiler diam, sehingga muncul pekerjaan yang tidak dimiliki dan tidak pernah dibatalkan siapa pun.
- Memaksa kode async jadi sinkron dengan DispatchSemaphore atau DispatchGroup.wait. Thread pool kooperatif kira-kira hanya punya satu thread per core CPU, jadi memblokirnya bisa bikin aplikasi macet atau deadlock.
- Menganggap method actor pasti jalan dari awal sampai akhir tanpa disela. Cek ulang state setiap habis await.
- Memakai Task.detached hanya supaya keluar dari main thread, padahal itu juga membuang prioritas dan nilai task-local.
- Membungkam error Sendable dengan @unchecked Sendable atau nonisolated(unsafe) tanpa ada lock di belakangnya.
- Decode JSON besar atau resize gambar di dalam view model @MainActor, lalu heran kenapa scroll-nya patah-patah.
Kalau Anda harus menambahkan @unchecked Sendable supaya build lolos, data race-nya belum beres. Anda cuma menyembunyikannya dari satu-satunya alat yang sedang berusaha menemukannya.
Testing Kode Async
XCTest dan Swift Testing sama-sama menerima fungsi test async, jadi sebagian besar boilerplate expectation dan waitForExpectations bisa dibuang. Tandai test dengan async throws lalu await pemanggilannya langsung. Di Swift Testing, pakai confirmation() untuk callback atau event yang harus terpanggil sekian kali. Inject API client lewat protocol supaya test memakai versi palsu yang langsung mengembalikan hasil, dan hindari Task.sleep sungguhan di dalam test. Inject Clock saja, supaya debounce bisa dites tanpa menunggu. Buat minimal satu test yang membatalkan task lalu memastikan task itu berhenti, karena bug async paling sering bersembunyi di jalur cancellation. Swift Testing juga menjalankan test secara paralel secara default, dan ini sering membongkar state global yang tidak Anda sadari ada.
Kebanyakan tim yang kami temui sudah menulis async/await di sana-sini. Yang belum ada biasanya kesepakatan soal actor, Sendable, dan cara migrasi ke Swift 6. Training iOS Swift kami untuk tim membahas topik-topik ini lewat latihan di codebase yang mirip project sungguhan, supaya semua anggota tim me-review kode concurrency dengan aturan yang sama.
Poin penting
- Migrasi mulai dari layer networking ke atas, dan bungkus API callback lama dengan checked continuation.
- Cancellation bersifat kooperatif, jadi cek di loop buatan sendiri dan simpan handle task yang Anda mulai.
- Pakai async let dan task group untuk pekerjaan paralel, dan pakai Task biasa hanya untuk menjembatani dari kode sinkron.
- Ingat reentrancy pada actor, dan bagikan pekerjaan yang sedang berjalan seperti refresh token daripada mengulanginya.
- Nyalakan strict concurrency checking per modul, mulai dari modul paling ujung dan akhiri di target aplikasi.


