Why Your JavaScript Promises Are Silently Failing Kenapa JavaScript Promise Kamu Gagal Tanpa Peringatan
Ajie Kusumadhany
You deploy your app on a Friday afternoon. Everything looks fine. No red banners, no crashes, no angry Slack messages — at least not immediately.
Then, quietly, things start going wrong. API calls that should update your database simply… don't. A payment flow silently stops halfway. A user's session never gets saved.
The culprit? An unhandled Promise rejection sitting in your code like an unexploded bomb.
This is one of the most dangerous failure modes in modern JavaScript — not because it's hard to fix, but because it's completely invisible until something truly breaks. And by then, the damage is already done.
The Promise Model: A Quick Refresher
Promises are JavaScript's native solution for handling asynchronous operations. Instead of deeply nested callbacks, they give you a clean chain of .then() and .catch() calls.
Every Promise exists in one of three states:
- Pending — the async operation is still in progress
- Fulfilled — the operation completed successfully
- Rejected — the operation failed for some reason
The problem isn't the model itself. The problem is what happens when a Promise transitions to Rejected and nobody is listening.
What "Silent Failure" Actually Looks Like
Consider this common pattern you've probably written before:
async function saveUserProfile(userId, data) {
const user = await db.findById(userId);
user.update(data);
await user.save();
}
// Called somewhere else in the app:
saveUserProfile(123, { name: "Ajie" });
Looks clean, right? But there's no await on the calling side, and no .catch() attached.
If db.findById throws — maybe the database is momentarily unavailable — the rejection floats up through the async function and goes completely unhandled.
In older Node.js versions, this would silently swallow the error. In newer versions (v15+), Node.js will actually crash your entire process with an UnhandledPromiseRejectionWarning.
Neither outcome is good.
The Five Most Common Promise Mistakes
1. Forgetting to Await Inside an Async Function
async function deleteRecord(id) {
db.delete(id); // Missing await!
return { success: true };
}
This function will return { success: true } immediately, before the deletion even completes. If the delete fails, you'll never know.
2. Mixing .then() and async/await
async function fetchData() {
return fetch("/api/data")
.then(res => res.json())
// No .catch() here
}
Mixing styles isn't inherently wrong, but it creates blind spots. A missing .catch() on a .then() chain inside an async function can let errors slip through without being caught by the outer try/catch.
3. The Floating Promise Anti-Pattern
function handleClick() {
doSomethingAsync(); // Not awaited, not .catch()'d
updateUI();
}
Event handlers are a classic breeding ground for floating Promises. The async work gets kicked off, the UI updates, and any errors vanish into the void.
4. Swallowing Errors in a Catch Block
try {
await riskyOperation();
} catch (err) {
// Do nothing 🤦
}
An empty catch block is arguably worse than no error handling at all. You're acknowledging the error exists — and then pretending it doesn't.
5. Promise.all() Without Granular Error Handling
const [users, orders] = await Promise.all([
fetchUsers(),
fetchOrders()
]);
If fetchOrders() rejects, Promise.all() immediately rejects the entire batch. Your users data — which fetched just fine — is lost too.
For independent operations, Promise.allSettled() is almost always the better choice.
Promise.all vs Promise.allSettled — Know the Difference
| Method | Behavior on Rejection | Best Used When |
|---|---|---|
Promise.all() |
Rejects immediately on first failure (fail-fast) | All operations are interdependent |
Promise.allSettled() |
Waits for all, returns results for each | Operations are independent |
Promise.race() |
Settles with the first to resolve or reject | Timeout patterns, first-response-wins |
Promise.any() |
Rejects only if all promises reject | Fallback sources, redundant fetches |
Choosing the right combinator is half the battle. Most developers default to Promise.all() out of habit, even when their operations are completely independent.
Building a Bulletproof Async Pattern
The goal isn't to wrap everything in a try/catch and call it a day. The goal is to make errors visible, trackable, and recoverable.
Pattern 1: The Safe Wrapper
Create a utility function that converts rejected Promises into a predictable return structure:
async function safe(promise) {
try {
const data = await promise;
return [null, data];
} catch (err) {
return [err, null];
}
}
// Usage:
const [err, user] = await safe(fetchUser(123));
if (err) {
console.error("Failed to fetch user:", err.message);
return;
}
// Proceed safely with user
This pattern — inspired by Go's error handling — makes it structurally impossible to ignore an error without an explicit decision.
Pattern 2: Global Rejection Handlers
As a safety net (not a substitute for proper handling), set up global listeners:
// In Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
// Alert your monitoring service (e.g., Sentry)
});
// In the browser
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
});
This won't fix the underlying bug, but it will ensure you know when one happens — before your users find out first.
Pattern 3: Async IIFE for Top-Level Async
Top-level await isn't available in all environments. When you need to run async code at the module level:
(async () => {
try {
await initializeApp();
} catch (err) {
console.error("Fatal startup error:", err);
process.exit(1);
}
})();
Explicit, deliberate, and impossible to silently fail.
TypeScript Makes This Even Better
TypeScript can't catch unhandled rejections at compile time, but it can enforce return types that make you think about error states:
type Result =
| { success: true; data: T }
| { success: false; error: Error };
async function fetchUser(id: number): Promise> {
try {
const user = await db.findById(id);
return { success: true, data: user };
} catch (error) {
return { success: false, error: error as Error };
}
}
When callers receive a Result type, they're forced to check the success flag before accessing data. The error state is baked into the type system itself.
Linting Your Way to Safety
You don't have to rely on discipline alone. ESLint has rules specifically designed to catch floating Promises:
@typescript-eslint/no-floating-promises— flags any Promise that isn't awaited or.catch()'d@typescript-eslint/no-misused-promises— catches async functions used in contexts that don't handle Promises (like event listeners)promise/catch-or-return— from theeslint-plugin-promisepackage, enforces that every Promise chain is terminated properly
Add these to your ESLint config and watch a surprising number of latent bugs surface immediately.
Pro Tips for Bulletproof Async Code
- Never fire-and-forget unless you explicitly want to — and if you do, at least attach a
.catch(err => logger.error(err)). - Use
Promise.allSettled()for any batch of independent async operations. It's almost always what you actually want. - Treat every
catchblock as production code — log the error, notify your monitoring service, and return a meaningful response. - Enable the ESLint floating-promises rule on every TypeScript project. It will catch things code review misses.
- Test your error paths, not just your happy paths. Mock failures in your unit tests and verify the app behaves gracefully.
- Never swallow errors silently. An empty
catchblock is a ticking clock, not a safety net.
Async JavaScript is one of the most powerful — and most misused — features of the language. The gap between code that looks correct and code that is correct has never been wider than in async error handling.
The good news? Every single pattern covered here is straightforward to implement. You don't need a library, a framework, or a major refactor. You just need to stop letting Promises go unlistened — and start treating every rejection as the production incident it could become.
Kamu deploy aplikasi di hari Jumat sore. Semua terlihat baik-baik saja. Tidak ada banner merah, tidak ada crash, tidak ada pesan Slack yang marah — setidaknya tidak langsung.
Lalu, secara diam-diam, semuanya mulai berjalan tidak beres. Panggilan API yang seharusnya memperbarui database begitu saja… tidak jalan. Alur pembayaran berhenti di tengah jalan tanpa peringatan. Sesi pengguna tidak pernah tersimpan.
Pelakunya? Sebuah Promise rejection yang tidak ditangani, duduk manis di kode kamu seperti bom yang belum meledak.
Ini adalah salah satu mode kegagalan paling berbahaya di JavaScript modern — bukan karena sulit diperbaiki, tapi karena sama sekali tidak terlihat sampai sesuatu benar-benar rusak. Dan saat itu terjadi, kerusakannya sudah terlanjur ada.
Model Promise: Kilasan Singkat
Promise adalah solusi native JavaScript untuk menangani operasi asinkron. Alih-alih callback yang bersarang dalam, Promise memberi kamu rantai .then() dan .catch() yang bersih.
Setiap Promise berada dalam salah satu dari tiga state:
- Pending — operasi async masih berjalan
- Fulfilled — operasi selesai dengan sukses
- Rejected — operasi gagal karena suatu alasan
Masalahnya bukan pada modelnya. Masalahnya adalah apa yang terjadi ketika sebuah Promise berpindah ke state Rejected dan tidak ada yang mendengarkannya.
Seperti Apa "Kegagalan Diam-Diam" Itu
Perhatikan pola umum ini yang mungkin pernah kamu tulis:
async function saveUserProfile(userId, data) {
const user = await db.findById(userId);
user.update(data);
await user.save();
}
// Dipanggil di tempat lain dalam aplikasi:
saveUserProfile(123, { name: "Ajie" });
Terlihat bersih, bukan? Tapi tidak ada await di sisi pemanggil, dan tidak ada .catch() yang terpasang.
Jika db.findById melempar error — mungkin database sedang tidak tersedia sejenak — rejection akan naik melalui fungsi async dan sama sekali tidak ditangani.
Di versi Node.js lama, ini akan menelan error secara diam-diam. Di versi baru (v15+), Node.js akan mencrash seluruh proses dengan UnhandledPromiseRejectionWarning.
Keduanya bukan pilihan yang baik.
Lima Kesalahan Promise Paling Umum
1. Lupa Await di Dalam Fungsi Async
async function deleteRecord(id) {
db.delete(id); // Kurang await!
return { success: true };
}
Fungsi ini akan langsung mengembalikan { success: true }, sebelum penghapusan selesai. Jika delete gagal, kamu tidak akan pernah tahu.
2. Mencampur .then() dan async/await
async function fetchData() {
return fetch("/api/data")
.then(res => res.json())
// Tidak ada .catch() di sini
}
Mencampur gaya tidak selalu salah, tapi menciptakan blind spot. Sebuah .catch() yang hilang pada rantai .then() di dalam fungsi async bisa membuat error lolos tanpa tertangkap oleh try/catch di luar.
3. Anti-Pola Floating Promise
function handleClick() {
doSomethingAsync(); // Tidak di-await, tidak di-.catch()
updateUI();
}
Event handler adalah tempat paling umum munculnya floating Promise. Pekerjaan async dimulai, UI diperbarui, dan semua error menghilang begitu saja.
4. Menelan Error di Blok Catch
try {
await riskyOperation();
} catch (err) {
// Tidak melakukan apa-apa 🤦
}
Blok catch yang kosong boleh dibilang lebih buruk dari tidak ada penanganan error sama sekali. Kamu mengakui errornya ada — lalu pura-pura tidak terjadi apa-apa.
5. Promise.all() Tanpa Penanganan Error yang Granular
const [users, orders] = await Promise.all([
fetchUsers(),
fetchOrders()
]);
Jika fetchOrders() reject, Promise.all() akan langsung me-reject seluruh batch. Data users kamu — yang berhasil di-fetch dengan baik — juga ikut hilang.
Untuk operasi yang independen, Promise.allSettled() hampir selalu merupakan pilihan yang lebih baik.
Promise.all vs Promise.allSettled — Kenali Perbedaannya
| Method | Perilaku Saat Rejection | Paling Baik Digunakan Ketika |
|---|---|---|
Promise.all() |
Langsung reject saat ada satu kegagalan (fail-fast) | Semua operasi saling bergantung |
Promise.allSettled() |
Menunggu semua selesai, mengembalikan hasil masing-masing | Operasi bersifat independen |
Promise.race() |
Settle dengan yang pertama resolve atau reject | Pola timeout, first-response-wins |
Promise.any() |
Hanya reject jika semua promise reject | Sumber fallback, fetch redundan |
Memilih combinator yang tepat adalah setengah dari pertarungan. Sebagian besar developer default ke Promise.all() karena kebiasaan, bahkan saat operasinya benar-benar independen.
Membangun Pola Async yang Anti Peluru
Tujuannya bukan membungkus segalanya dalam try/catch lalu selesai. Tujuannya adalah membuat error terlihat, terlacak, dan dapat dipulihkan.
Pola 1: Safe Wrapper
Buat fungsi utilitas yang mengubah Promise yang di-reject menjadi struktur return yang dapat diprediksi:
async function safe(promise) {
try {
const data = await promise;
return [null, data];
} catch (err) {
return [err, null];
}
}
// Penggunaan:
const [err, user] = await safe(fetchUser(123));
if (err) {
console.error("Gagal mengambil user:", err.message);
return;
}
// Lanjutkan dengan aman menggunakan user
Pola ini — terinspirasi dari penanganan error Go — membuat kamu secara struktural mustahil mengabaikan error tanpa keputusan yang eksplisit.
Pola 2: Global Rejection Handler
Sebagai jaring pengaman (bukan pengganti penanganan yang tepat), pasang listener global:
// Di Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
// Beri tahu layanan monitoring (mis. Sentry)
});
// Di browser
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
});
Ini tidak memperbaiki bug yang mendasarinya, tapi memastikan kamu tahu saat ada yang terjadi — sebelum pengguna yang menemukannya duluan.
Pola 3: Async IIFE untuk Async Level Atas
Top-level await tidak tersedia di semua environment. Ketika kamu perlu menjalankan kode async di level modul:
(async () => {
try {
await initializeApp();
} catch (err) {
console.error("Fatal startup error:", err);
process.exit(1);
}
})();
Eksplisit, disengaja, dan mustahil gagal secara diam-diam.
TypeScript Membuat Ini Lebih Baik Lagi
TypeScript tidak bisa menangkap unhandled rejection saat compile time, tapi bisa memberlakukan return type yang membuatmu memikirkan state error:
type Result =
| { success: true; data: T }
| { success: false; error: Error };
async function fetchUser(id: number): Promise> {
try {
const user = await db.findById(id);
return { success: true, data: user };
} catch (error) {
return { success: false, error: error as Error };
}
}
Ketika pemanggil menerima tipe Result, mereka dipaksa memeriksa flag success sebelum mengakses data. State error sudah tertanam dalam sistem tipe itu sendiri.
Linting Sebagai Lapis Keamanan Tambahan
Kamu tidak harus bergantung pada disiplin semata. ESLint memiliki aturan yang dirancang khusus untuk menangkap floating Promise:
@typescript-eslint/no-floating-promises— menandai setiap Promise yang tidak di-await atau di-.catch()@typescript-eslint/no-misused-promises— menangkap fungsi async yang digunakan dalam konteks yang tidak menangani Promise (seperti event listener)promise/catch-or-return— dari paketeslint-plugin-promise, memastikan setiap rantai Promise diakhiri dengan benar
Tambahkan aturan-aturan ini ke konfigurasi ESLint kamu dan saksikan sejumlah bug laten yang mengejutkan langsung muncul ke permukaan.
Tips Praktis untuk Kode Async yang Tangguh
- Jangan pernah fire-and-forget kecuali kamu memang sengaja — dan jika iya, setidaknya pasang
.catch(err => logger.error(err)). - Gunakan
Promise.allSettled()untuk setiap batch operasi async yang independen. Hampir selalu itu yang sebenarnya kamu butuhkan. - Perlakukan setiap blok
catchsebagai kode produksi — log errornya, beri tahu layanan monitoring, dan kembalikan respons yang bermakna. - Aktifkan aturan ESLint floating-promises di setiap proyek TypeScript. Aturan ini akan menangkap hal yang terlewat saat code review.
- Uji jalur error kamu, bukan hanya happy path. Mock kegagalan di unit test dan verifikasi aplikasi berperilaku dengan baik.
- Jangan pernah menelan error secara diam-diam. Blok
catchyang kosong adalah jam yang berdetik, bukan jaring pengaman.
JavaScript async adalah salah satu fitur paling powerful — dan paling disalahgunakan — dari bahasa ini. Celah antara kode yang terlihat benar dan kode yang memang benar tidak pernah selebar ini dibanding dalam penanganan error async.
Kabar baiknya? Setiap pola yang dibahas di sini mudah untuk diimplementasikan. Kamu tidak butuh library, framework, atau refactor besar. Kamu hanya perlu berhenti membiarkan Promise tak didengar — dan mulai memperlakukan setiap rejection sebagai insiden produksi yang berpotensi terjadi.