Sintaks async/await JavaScript seharusnya menyelamatkan kita dari callback hell. Sebaliknya, ia memperkenalkan masalah yang lebih tenang namun lebih berbahaya: kode yang terlihat benar tetapi berperilaku tidak terduga. Anda melihat await ada di dalam bodi fungsi dan berasumsi semuanya akan berhenti sejenak dengan rapi, baris demi baris. Sering kali tidak demikian. Loop berjalan terlalu cepat. Seluruh batch runtuh hanya karena satu permintaan gagal. File entri menumbuhkan pembungkus (wrapper) async yang buruk tanpa alasan yang jelas. Jika Anda pernah mengalami salah satu dari hal ini, tiga pola berikut akan memperbaikinya.
Berhenti Menggunakan await di Dalam forEach
Berikut adalah kesalahan umum yang terlihat tidak berbahaya di halaman:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
Jalankan ini, dan 'All done!' akan tercetak sebelum satu pun respons kembali. Mengapa? forEach mengeksekusi callback untuk setiap elemen secara instan. Ia tidak menunggu promise di dalam setiap iterasi. Kata kunci async mengubah setiap callback menjadi promise yang segera diabaikan oleh forEach. Loop Anda selesai dalam hitungan mikrodetik; permintaan jaringan berjalan sendiri tanpa kendali. Jika Anda perlu menangani error secara berurutan, atau jika Anda perlu menjamin satu permintaan selesai sebelum permintaan berikutnya dimulai, pola ini secara diam-diam merusak kedua jaminan tersebut.
Ganti dengan loop for...of:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
Sekarang loop benar-benar berhenti di setiap await. Permintaan kedua menunggu permintaan pertama. 'All done!' baru tercetak setelah semuanya selesai.
Gunakan for...of saat urutan itu penting—mengunggah file satu per satu untuk mematuhi rate limit, menulis baris database dalam urutan tertentu, atau merantai panggilan API di mana permintaan berikutnya membutuhkan data dari respons sebelumnya. Jika Anda sebenarnya menginginkan eksekusi paralel, jangan mencoba-coba dengan forEach. Gunakan Promise.all secara eksplisit agar niat Anda terlihat jelas oleh pengembang berikutnya. Namun, jangan pernah mencampur await dan forEach dengan mengharapkan perilaku sinkron. Itu tidak akan terjadi.
Gunakan Promise.allSettled Saat "Nol" Bukanlah Jawaban yang Diinginkan
Promise.all secara semantik jujur. Berikan array berisi promise, dan ia akan mengembalikan array berisi hasil. Masalahnya adalah saat satu saja promise ditolak (reject), seluruh proses akan langsung ditolak. Setiap promise lain yang sedang berjalan dibiarkan selesai sendiri, tetapi Anda kehilangan akses ke hasil mereka. Di lingkungan produksi, perilaku "semua atau tidak sama sekali" ini sangat merugikan.
Bayangkan aplikasi Anda mengambil widget dasbor dari empat layanan independen: analitik trafik, data pendapatan, umpan balik pengguna, dan kesehatan server. API pendapatan mengalami timeout singkat. Di bawah Promise.all, seluruh dasbor Anda akan memunculkan error. Tiga respons yang sehat hilang begitu saja. Pengguna melihat spinner, lalu layar kegagalan, hanya karena seperempat datanya bermasalah.
Promise.allSettled memberi Anda kontrak yang lebih masuk akal. Ia menunggu sampai setiap promise selesai, apa pun hasilnya. Nilai yang terselesaikan (resolved) adalah sebuah array objek yang mendeskripsikan setiap hasil:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
Tidak ada respons yang dibuang. Anda merender apa yang bisa dan mengisolasi kegagalan. Pola ini penting kapan pun Anda berurusan dengan operasi yang tidak terkait—notifikasi massal, pengiriman webhook pihak ketiga, atau mengimpor catatan dari beberapa aliran CSV. Anda tetap memerlukan pelacakan error terpusat, tetapi aplikasi Anda tetap berjalan stabil.
Satu catatan praktis: allSettled mengembalikan set lengkap, jadi Anda tetap perlu menyaring hasil dan memutuskan apa arti "keberhasilan parsial" bagi fitur Anda. Jangan menganggap array yang dikembalikan sebagai data yang semuanya sukses. Periksa kolom status tersebut sebelum Anda memasukkan apa pun ke dalam lapisan state Anda.
Deklarasikan Top-Level await dan Hentikan Penggunaan Wrapper IIFE
Selama bertahun-tahun, jika Anda ingin melakukan await pada sesuatu di akar (root) sebuah file, Anda membungkusnya dalam fungsi async yang langsung dipanggil (immediately invoked async function):
(async () => {
const config = await loadConfig();
startServer(config);
})();
Ini berhasil, tetapi hanya menambah kebisingan kode. Top-level await, yang merupakan fitur asli di ES modules, memungkinkan Anda membuang kode boilerplate yang tidak perlu:
const config = await loadConfig();
startServer(config);
Gunakan ini pada titik entri (entry point) aplikasi Anda atau dalam modul konfigurasi khusus di mana inisialisasi harus selesai sebelum hal lain berjalan. Memuat file lingkungan (environment files), membangun connection pool database, atau mengambil remote feature flags adalah contoh penggunaan yang sangat tepat. Karena top-level await memblokir eksekusi grafik modul—file lain yang mengimpor file ini akan menunggu promise Anda terselesaikan—Anda mendapatkan status yang terjamin. Sisa kode Anda dapat mengimpor db dan mengetahui bahwa koneksi sudah aktif.
There are two catches. First, your runtime or bundler must support ES modules. In Node.js, that means either using .mjs extensions or setting "type": "module" in your package.json. Second, because waiting at the module level delays every importer, keep the awaited work focused. Heavy sequential fetches at the top of a frequently imported utility file will slow down your entire application cold start. Reserve top-level await for true bootstrap tasks that other modules genuinely depend upon.
What Actually Changes When You Adopt These Patterns
Predictability is the first payoff. When you read a for...of loop, you know exactly when the block underneath finishes. There are no ghost promises racing behind the scenes, no foreach callbacks detaching from your error handlers. Your control flow matches the shape of the code on the screen.
Resilience comes next. Promise.allSettled forces you to think about partial failure instead of hoping every external system stays perfect. Production software is not binary. Some endpoints will flake. Some file reads will hit permission errors. Designing for the reality of scattered failures keeps your application upright without sweeping legitimate data under the rug.
Clarity ties it together. for...of reads like plain English progression. allSettled states its intent in its name. Top-level await removes cryptic IIFE wrappers so your entry files start with business logic instead of syntactic acrobatics. The next engineer who touches the file—whether that is you in six months or a teammate on a deadline—will thank you.
A Real Takeaway
Do not treat async/await as a global fix that you sprinkle over existing code. Audit your current projects for these three specific anti-patterns. Search for await inside forEach blocks and replace them with for...of or an intentional Promise.all. Review every Promise.all that talks to external services and ask whether a single failure should really torpedo the entire operation; if not, switch to Promise.allSettled and handle the mixed results. Finally, strip the async IIFEs out of your ES module entry points and let top-level await handle your bootstrap sequence directly. These are small mechanical changes, but together they turn fragile asynchronous scripts into code you can actually trust.
