چرا خطای Unhandled Promise Rejection رخ میدهد و چگونه رفع میشود؟
خطای Unhandled Promise Rejection در جاوااسکریپت چیست، چرا نادیده گرفته میشود و چگونه آن را قبل از اینکه در production دردسر بسازد پیدا و رفع کنیم؟ راهنمای عملی.
یادم میآید اولین باری که پیام قرمز Unhandled Promise Rejection را در کنسول مرورگر دیدم، فکر کردم یک خطای بیاهمیت است و بستمش. چند روز بعد، درست وسط یک کمپین فروش، پرداختهای فروشگاه از کار افتاد و مشتریها شروع کردند به شکایت. آن روز فهمیدم که Promise Rejection نادیدهگرفتهشده، مثل یک نشتی کوچک در لوله است: امروز قطره است، فردا سیل. این مقاله، حاصل همان درس گرانقیمت و سالها تجربه بعدی است.
Unhandled Promise Rejection دقیقاً چیست؟
Unhandled Promise Rejection یک وضعیت در JavaScript است که وقتی رخ میدهد که یک Promise (وعده) به حالت reject (رد) برود، ولی هیچکس آن را با .catch() یا try/catch در محیط async/await مدیریت نکند. به زبان سادهتر، یک عملیات ناهمگام (Asynchronous) شکست خورده است، ولی کسی نبوده که این شکست را بپذیرد و به آن واکنش نشان دهد.
در دنیای Promise، دو حالت نهایی وجود دارد: fulfilled و rejected. اگر یک Promise رد شود و شما هیچجا به آن گوش ندهید، JavaScript این را یک رفتار غیرقانونی میداند و از طریق مکانیزم Unhandled Promise Rejection به شما گزارش میدهد. این مکانیزم در نسخههای مدرن موتورهای جاوااسکریپت — مثل V8 در Chrome و Node.js — به صورت بومی پیادهسازی شده است.
نکته مهم این است که Unhandled Promise Rejection یک Exception معمولی نیست. یک throw معمولی در جاوااسکریپت فوراً زنجیره اجرا را قطع میکند و اگر گرفته نشود، برنامه را متوقف میکند. اما Promise Rejection در یک لایه ناهمگام رخ میدهد و به همین دلیل، اگر مدیریت نشود، به راحتی از دید توسعهدهنده پنهان میماند. به همین دلیل است که من همیشه به تیمهایی که تازه با Promise کار میکنند میگویم: این خطا، خطای سکوت است؛ اگر به آن گوش ندهید، فریادش را در جای بدی میشنوید.
از نظر مفهومی، Promise در JavaScript بر اساس همان مفهوم Futures و Promises در علوم کامپیوتر طراحی شده است؛ مفهومی که سالها پیش برای مدیریت محاسبات ناهمگام در زبانهای تابعی مطرح شد. آشنایی با این پیشینه، به شما کمک میکند تا رفتار Promise در جاوااسکریپت را بهتر درک کنید.
Promise نادیدهگرفتهشده، مثل بدهی است: امروز خبری از آن نیست، فردا با سود آن را میپردازید.
چرا این خطا رخ میدهد؟
برای فهم علت، باید یک نکته اساسی درباره Promise بدانید: هر Promise که reject میشود، باید توسط یک catch در همان زنجیره گرفته شود. اگر این کار انجام نشود، JavaScript نمیداند که شما به نتیجه نهایی آن عملیات اهمیت میدهید یا نه. در واقع، این مکانیزم برای این طراحی شده که جلوی شکستهای خاموش را بگیرد.
مثال سادهای که این رفتار را روشن میکند:
fetch("/api/user")
.then((response) => response.json())
.then((data) => console.log(data));
// اگر fetch یا response.json خطا بدهد، هیچ catchای وجود ندارد
// و Unhandled Promise Rejection رخ میدهد
در این کد، اگر سرور پاسخ خطا بدهد یا شبکه قطع شود، Promise رد میشود ولی هیچکس آن را نمیگیرد. حالا اگر همان کد را با .catch() بنویسید، مشکل حل میشود:
fetch("/api/user")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("خطا در دریافت کاربر:", error));
چرا این خطا در سالهای اخیر اینقدر رایج شده است؟ به این دلیل که الگوی async/await در ES2017 معرفی شد و بسیاری از توسعهدهندگان به راحتی از try/catch در اطراف توابع async غافل میشوند. تفاوت این دو الگو را میتوانید در async و await در جاوااسکریپت ببینید. اگر با مبانی Promise آشنا نیستید، پیشنهاد میکنم ابتدا Promise در جاوااسکریپت را بخوانید.
الگوهای رایج بروز این خطا در پروژههای واقعی
در سالهای کار روی پروژههای مختلف، الگوهای تکراریای برای بروز Unhandled Promise Rejection دیدهام. این الگوها را در جدول زیر جمع کردهام تا بتوانید سریعتر موقعیت خودتان را تشخیص دهید:
| الگو | نمونه کد مشکلدار | فراوانی در پروژهها |
|---|---|---|
| فراخوانی fetch بدون catch | fetch(url).then(r => r.json()) | بسیار بالا |
| async function بدون try/catch | async function load() { await api(); } | بالا |
| Promise.all بدون مدیریت خطا | Promise.all([a, b, c]) | متوسط |
| رویداد Event بدون catch | فراخوانی async در addEventListener | بالا |
| setTimeout با تابع async | تابع ناهمگام داخل setTimeout | متوسط |
| کتابخانههای خارجی | خطای داخلی library بدون مدیریت | پایین ولی گیجکننده |
الگوی اول و دوم، یعنی fetch بدون catch و async function بدون try/catch، بیشترین سهم را در پروژههای واقعی دارند. جالب اینجاست که در هر دو حالت، کد از نظر منطقی میتواند درست باشد ولی وقتی خطا رخ میدهد، کاربر تجربه بدی میگیرد و شما هیچجا متوجه نمیشوید.
الگوی سوم، یعنی Promise.all بدون مدیریت خطا، یک نکته مهم دارد: Promise.all اگر یکی از Promiseهای ورودی رد شود، بلافاصله رد میشود و بقیه را رها میکند. این رفتار باعث میشود که خطا به بالا propagate کند، ولی اگر آن بالا هم catch نباشد، به Unhandled Promise Rejection تبدیل میشود. برای مدیریت دقیقتر، میتوانید از Promise.allSettled استفاده کنید که منتظر همه Promiseها میماند.
الگوی چهارم، یعنی فراخوانی تابع async در یک Event Listener، یکی از تلههای پنهان جاوااسکریپت است. وقتی داخل یک addEventListener یک تابع async صدا میزنید، هیچکسی مسئولیت catch کردن Rejection آن را بر عهده نمیگیرد. اگر با رویدادها آشنایی ندارید، ایونتها در جاوااسکریپت به شما دید بهتری میدهد.
هر جا که یک تابع async را صدا میزنید، بدون اینکه await کنید، یک Promise Rejection بالقوه ساختهاید که منتظر فرصت است.
تفاوت با خطاهای معمولی جاوااسکریپت
یکی از سوالاتی که زیاد در جلسات آموزشی میشنوم این است که تفاوت Unhandled Promise Rejection با SyntaxError یا TypeError چیست. تفاوت اصلی در زمان بروز و نحوه مدیریت است. SyntaxError قبل از اجرا رخ میدهد و کل فایل را از کار میاندازد. TypeError و ReferenceError در زمان اجرا رخ میدهند و اگر try/catch نداشته باشند، بلافاصله برنامه را متوقف میکنند. اما Unhandled Promise Rejection در یک لایه بالاتر از Runtime رخ میدهد — در واقع، Rejection اتفاق افتاده، ولی هیچکس آن را ندیده و به همین دلیل موتور جاوااسکریپت آن را گزارش میکند.
این تفاوت باعث میشود که Unhandled Promise Rejection در محیط مرورگر معمولاً فقط یک هشدار در Console باشد و برنامه به کار خود ادامه دهد؛ اما در Node.js مدرن، این خطا به صورت پیشفرض فرآیند را متوقف میکند. همین تفاوت رفتاری است که بسیاری از توسعهدهندگان را گیج میکند.
برای درک تفاوت با خطاهای دیگر، مقایسهای کاربردی مفید است. خطای مدیریت نشده Promise، مثل یک نامه بیپاسخ است: نامه رسیده، ولی کسی جوابش را نداده. در مقابل، TypeError مثل این است که نامه را اشتباه نوشته باشید — قبل از ارسال، مشکل مشخص است. اگر میخواهید انواع دیگر خطاها را بشناسید، خطای TypeError در JavaScript و رفع خطای ReferenceError در جاوااسکریپت را ببینید.
یک نکته فنی که کمتر گفته میشود این است که Unhandled Promise Rejection در برخی موتورها مثل V8، در انتهای هر tick event loop بررسی میشود. یعنی اگر در همان tick یک catch به Promise اضافه شود، دیگر Unhandled محسوب نمیشود. این رفتار باعث میشود که ترتیب اجرای کد شما در مدیریت این خطا نقش مستقیم داشته باشد.
چگونه خطای مدیریتنشده Promise را پیدا کنیم؟
پیدا کردن Unhandled Promise Rejection در پروژههای بزرگ میتواند سخت باشد، چون ممکن است در گوشهای از کد رخ دهد که شما فراموشش کردهاید. روش من برای تشخیص، ترکیبی از ابزارها و رویکردهای عملی است.
اولین و مهمترین ابزار، خود کنسول مرورگر است. در Chrome DevTools، زیر تب Console، گزینهای وجود دارد که تمام Unhandled Promise Rejectionها را با شماره خط و پیام خطا نشان میدهد. اگر با کنسول مرورگر کار نکردهاید، چگونه خطاهای جاوااسکریپت را در کنسول مرورگر پیدا کنیم راهنمای دقیقی است.
دومین ابزار، شنونده سراسری رویداد است. در مرورگر میتوانید به رویداد unhandledrejection گوش کنید و خطاها را به سیستم لاگ خود بفرستید:
window.addEventListener("unhandledrejection", (event) => {
console.error("Promise Rejection:", event.reason);
// ارسال به سیستم لاگ یا سرویس مانیتورینگ
event.preventDefault();
});
در Node.js هم رویداد مشابهی وجود دارد که میتوانید روی process گوش کنید. این الگو را من در همه پروژههای production خودم پیاده میکنم، چون بدون آن، بسیاری از خطاها به صورت خاموش باقی میمانند.
سومین ابزار، ابزارهای مانیتورینگ خطا مثل Sentry یا Rollbar است. این ابزارها به صورت خودکار Unhandled Promise Rejection را در محیط production ضبط میکنند و شما را با Stack Trace دقیق از موقعیت خطا مطلع میکنند. من در پروژههایی که بیش از چند هزار کاربر دارند، استفاده از این ابزارها را ضروری میدانم.
در پروژههای production، هر Promise Rejection که مدیریت نشود، یک بلیط لاتاری است؛ شاید امروز مشکلی نسازد، ولی فردا برنده بدشانسی شما میشود.
راهحلهای عملی و حرفهای
راهحلهای مدیریت Unhandled Promise Rejection را میتوان به چند سطح تقسیم کرد. در سطح پایه، شما هر Promise را با catch مدیریت میکنید. در سطح متوسط، از try/catch در توابع async استفاده میکنید. در سطح حرفهای، یک لایه مدیریت خطای سراسری پیادهسازی میکنید که تمام Rejectionهای احتمالی را در یک نقطه جمع میکند.
سطح پایه، همان .catch() است. هر جا که .then() مینویسید، باید یک .catch() در انتهای زنجیره داشته باشید. این قاعده ساده، بخش بزرگی از مشکل را حل میکند.
سطح متوسط، استفاده از try/catch در اطراف await است. هر جا که از await استفاده میکنید، اگر آن Promise رد شود، یک Exception پرتاب میشود. اگر شما try/catch نداشته باشید، آن Exception در محیط async به Unhandled Promise Rejection تبدیل میشود:
async function loadUser(id) {
try {
const response = await fetch(`/api/user/${id}`);
return await response.json();
} catch (error) {
console.error("خطا در بارگذاری کاربر:", error);
return null;
}
}
این الگو، تمیزترین و خواناترین راه مدیریت خطا در توابع async است. من در همه پروژههای تیمی این الگو را به عنوان استاندارد تعریف میکنم.
سطح حرفهای، اضافه کردن یک لایه مدیریت سراسری است. حتی با بهترین تلاشها، گاهی یک Promise در گوشهای از کد از قلم میافتد. برای این موارد، یک شنونده سراسری مثل unhandledrejection در مرورگر یا process.on("unhandledRejection") در Node.js ضروری است. این شنونده، خطاها را به سیستم لاگ شما ارسال میکند تا بتوانید آنها را بعداً تحلیل کنید. اگر روی بهینهسازی کد خود کار میکنید، بهینهسازی جاوااسکریپت به شما نشان میدهد که چطور ساختار کد خود را بهبود دهید.
نقش async/await در این خطا
یکی از نکات مهمی که در پروژههای واقعی زیاد میبینم، نادیده گرفتن تفاوت Promise chaining و async/await در بروز Unhandled Promise Rejection است. وقتی از Promise chaining استفاده میکنید، باید یادتان باشد که هر حلقه از زنجیره که Rejection را جلو نبرد، در انتهای زنجیره باید catch شود. اما در async/await، هر await که Rejection پرتاب کند، در محیط همان تابع async مدیریت میشود و اگر try/catch نباشد، به سطح بالاتر منتقل میشود.
یک تله رایج در async/await، این است که توسعهدهنده در یک تابع async، یک Promise دیگر را بدون await صدا میزند. در این حالت، Promise Rejection آن تابع داخلی، هیچوقت توسط try/catch بیرونی گرفته نمیشود، چون آن try/catch فقط Exceptionهای await را میگیرد. مثال:
async function main() {
try {
fetchData(); // بدون await - خطرناک
} catch (error) {
console.log("این catch هیچوقت برای fetchData اجرا نمیشود");
}
}
راهحل این تله، استفاده مداوم از await و در مواقع لازم، استفاده از return await است. برای درک عمیقتر این موضوع، async و await در جاوااسکریپت را کامل مطالعه کنید.
همچنین در بخشهایی که از Fetch API در جاوااسکریپت استفاده میکنید، باید بدانید که fetch فقط خطاهای شبکه را Reject میکند، نه خطاهای HTTP مثل 404 یا 500. یعنی یک پاسخ 500 ممکن است اصلاً Reject نشود و شما باید خودتان بعد از دریافت response بررسی کنید که response.ok باشد یا نه. این نکته، یکی از رایجترین دلایل باگهای پنهان در پروژههای front-end است.
تفاوت رفتار در مرورگر و Node.js
یکی از جنبههایی که در آموزشهای جاوااسکریپت کمتر به آن پرداخته میشود، تفاوت رفتار Unhandled Promise Rejection در مرورگر و Node.js است. در مرورگر، این خطا به صورت پیشفرض فقط در Console گزارش میشود و ادامه اجرای برنامه را متوقف نمیکند. اما در Node.js مدرن — بهخصوص از نسخه ۱۵ به بعد — این خطا به صورت پیشفرض فرآیند را با کد خروجی غیرصفر متوقف میکند.
این تفاوت، برای توسعهدهندگانی که در local با Node.js کار میکنند و کد را در production به مرورگر میفرستند، میتواند گیجکننده باشد. نکته عملی که در پروژهها رعایت میکنم این است که لایه مدیریت خطا را در هر دو محیط داشته باشم — چه با شنونده unhandledrejection در مرورگر، چه با process.on("unhandledRejection") در Node.js.
در Node.js، اگر میخواهید رفتار پیشفرض را تغییر دهید (که توصیه نمیکنم، مگر در موارد خاص)، میتوانید از فلگ --unhandled-rejections=warn استفاده کنید. اما تجربه من این است که در production، بهتر است بگذارید فرآیند متوقف شود و بعد از طریق سیستم مدیریت فرآیند (مثل PM2 یا systemd) راهاندازی مجدد انجام شود. این رویکرد باعث میشود که خطاها سریعتر شناسایی شوند.
اشتباهات رایج در مدیریت Promise
در طول سالها، اشتباهات تکراری زیادی در تیمهای مختلف دیدهام. اولین و شایعترین اشتباه، اضافه کردن catch فقط به انتهای زنجیره اول است. در حالی که هر حلقه از زنجیره میتواند Rejection جدیدی بسازد و باید در انتهای همان زنجیره مدیریت شود.
اشتباه دوم، استفاده از console.log بدون ارسال خطا به سیستم مرکزی است. اگر فقط در Console چاپ کنید، در production هیچکس خطا را نمیبیند. راه درست، ارسال خطا به یک سیستم مرکزی مثل Sentry یا Rollbar است.
اشتباه سوم، خالی گذاشتن بلوک catch است. من در پروژههای زیادی کدی دیدهام که در آن .catch(() => {}) یا catch (error) {} نوشته شده. این کار بدتر از نبودن catch است، چون خطا را بیصدا خفه میکند و شما هیچوقت نمیفهمید که چه اتفاقی افتاده. حداقل کار، چاپ خطا با جزئیات و ارسال به سیستم لاگ است.
اشتباه چهارم، استفاده از try/catch در محیطهای غیر async است. در JavaScript قدیمی، try/catch فقط برای کد همگام (Synchronous) کار میکرد. اگر همین try/catch را برای یک تابع async بدون await بنویسید، خطا گرفته نمیشود. این نکته، بخش بزرگی از سردرگمیهای توسعهدهندگان تازهکار را توضیح میدهد.
اشتباه پنجم، بیتوجهی به Promiseهای زنجیرهای در Event Listenerها است. وقتی یک تابع async را در addEventListener صدا میزنید، معمولاً این فراخوانی به صورت ضمنی Unhandled است، چون مرورگر نمیداند که باید منتظر آن بماند. برای مدیریت صحیح، باید یا داخل همان تابع async try/catch داشته باشید، یا از یک لایه سراسری استفاده کنید.
حذف صدای خطا، حذف خطا نیست؛ فقط آن را به آینده پاس میدهید.
پیشگیری از بروز خطا در پروژه
پیشگیری، همیشه بهتر از درمان است. اولین قدم برای پیشگیری از Unhandled Promise Rejection، تعریف یک استاندارد تیمی است. در تیمهایی که من مدیریت کردهام، همیشه یک Rule ساده داشتهایم: هر Promise، در همان فایل و همان زنجیره، catch داشته باشد. این قانون ساده، بخش بزرگی از مشکل را حل میکند.
دومین قدم، استفاده از Linter و ابزارهای Static Analysis است. ESLint با قوانینی مثل no-floating-promises یا no-misused-promises میتواند Promiseهای بدون مدیریت را قبل از اجرا شناسایی کند. این ابزارها، مثل یک داور بیطرف عمل میکنند و در Code Review هم به شما کمک میکنند.
سومین قدم، نوشتن تست است. تستهایی که سناریوهای خطا را پوشش میدهند، میتوانند Promise Rejectionها را قبل از production شناسایی کنند. Jest و Vitest ابزارهای خوبی برای این کار هستند. اگر روی ساختار پروژه کار میکنید، مفاهیم پایه جاوااسکریپت و شی گرایی در جاوااسکریپت به شما کمک میکند تا الگوهای تمیزتری برای مدیریت Promise طراحی کنید.
چهارمین قدم، داشتن یک لایه مدیریت سراسری است. حتی با بهترین پیشگیریها، گاهی یک Promise Rejection از گوشهای بیرون میزند. یک شنونده سراسری که خطا را به Sentry یا سیستم لاگ شما میفرستد، در این موارد نجاتبخش است. این لایه، مکمل استاندارد تیمی است، نه جایگزین آن.
پرسشهای پرتکرار درباره Unhandled Promise Rejection
این پرسشها را در جلسات مشاوره و کامنتهای مقالات زیاد دیدهام و پاسخ هرکدام را بر اساس تجربه واقعی میدهم.
آیا Unhandled Promise Rejection باعث توقف برنامه میشود؟ بستگی به محیط دارد. در مرورگر معمولاً فقط در Console گزارش میشود و برنامه ادامه میدهد. در Node.js مدرن، به صورت پیشفرض فرآیند متوقف میشود. در هر دو حالت، این خطا نشانه یک مشکل واقعی است و نباید نادیده گرفته شود.
چرا catch در انتهای زنجیره اصلی، Rejectionهای زنجیرههای داخلی را نمیگیرد؟ چون هر زنجیره مستقل خودش است. یک catch فقط Rejectionهایی را میگیرد که در همان زنجیره به بالا منتقل شدهاند. اگر یک Promise دیگر را بدون await صدا بزنید، آن یک زنجیره جداگانه است که catch آن نمیگیرد.
آیا میتوانم Promise Rejection را کاملاً نادیده بگیرم؟ از نظر فنی بله، ولی از نظر مهندسی خیر. نادیده گرفتن Rejection یعنی نادیده گرفتن خطاهایی که میتوانند تجربه کاربر را خراب کنند. حتی اگر خطا در بخشی از برنامه باشد که اهمیت کمی دارد، بهتر است آن را لاگ کنید تا در آینده بتوانید تحلیل کنید.
تفاوت Unhandled Promise Rejection با Uncaught Exception چیست؟ Uncaught Exception مربوط به خطاهای همگام است که هیچ try/catch نگرفته. Unhandled Promise Rejection مربوط به خطاهای ناهمگام است که هیچ catch در زنجیره Promise نگرفته. هر دو نشانه یک مشکل مدیریت خطا هستند، ولی در لایههای مختلف اجرا رخ میدهند.
آیا async/await همه Promise Rejectionها را میگیرد؟ نه، فقط Promiseهایی که با await صدا زده شده باشند. اگر داخل تابع async، یک Promise بدون await صدا بزنید، آن Promise در یک زنجیره جدا اجرا میشود و try/catch بیرونی آن را نمیگیرد. این نکته، یک تله رایج در پروژههای واقعی است.
چطور میتوانم Unhandled Promise Rejection را در محیط production رصد کنم؟ با ترکیب دو ابزار: یک شنونده سراسری که خطاها را میگیرد، و یک سیستم مانیتورینگ مثل Sentry یا Rollbar که خطاها را در یک داشبورد متمرکز نشان میدهد. این ترکیب به شما امکان میدهد که خطاها را در همان ساعت اول شناسایی کنید، نه در گزارش شکایت کاربران.
نگاه آخر: Promise، ابزار یا دام؟
Promise یکی از قویترین و در عین حال پیچیدهترین مفاهیم جاوااسکریپت است. اگر درست استفاده شود، کد شما را خوانا، قابل نگهداری و مقیاسپذیر میکند. اگر اشتباه استفاده شود، به یک دام تبدیل میشود که خطاهایش به سختی پیدا میشوند و به سرعت در production مشکلات جدی ایجاد میکنند.
Unhandled Promise Rejection، در نهایت، پیام دوستانهای است از موتور جاوااسکریپت که به شما میگوید یک چیزی در کدتان از قلم افتاده. به جای اینکه این پیام را نادیده بگیرید یا با catch(() => {}) خفهاش کنید، به آن به عنوان یک فرصت برای بهبود کیفیت کد نگاه کنید. تجربه من این است که توسعهدهندگانی که این نگاه را دارند، خیلی سریعتر از سایرین رشد میکنند. 🙂
اگر تجربهای با Unhandled Promise Rejection در پروژههای واقعی داشتهاید — مخصوصاً اگر خطایی داشتید که پیدا کردنش ساعتها وقت گرفت — در دیدگاهها بنویسید. این تجربهها برای من و خوانندگان بعدی ارزشمند است؛ بهویژه اگر خطایی از یک کتابخانه خارجی یا از رفتار غیرمنتظره یک مرورگر خاص ناشی شده باشد.