תחביר ה-async/await של JavaScript נועד להציל אותנו מ"גיהנום של קולבקים" (callback hell). במקום זאת, הוא הציג בעיה שקטה וערמומית יותר: קוד שנראה תקין אך מתנהג בצורה בלתי צפויה. אתם רואים await יושב שם בגוף הפונקציה ומניחים שהכל עוצר בנימוס, שורה אחר שורה. לעיתים קרובות זה לא קורה. לולאות רצות קדימה. סדרות שלמות של פעולות קורסות בגלל בקשה אחת שנכשלה. קבצי כניסה (entry files) מפתחים עטיפות async מכוערות ללא סיבה. אם נתקלתם באחד מאלה, שלושת התבניות הבאות יסדרו לכם אותם.

הפסיקו להשתמש ב-await בתוך forEach

הנה טעות נפוצה שנראית בלתי מזיקה על הנייר:

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!');

הריצו את זה, ו-'All done!' יודפס לפני שמתקבלת אפילו תגובה אחת. למה? forEach מריץ את ה-callback עבור כל איבר באופן מיידי. הוא לא מחכה ל-promise שבתוך כל איטרציה. מילת המפתח async הופכת כל callback ל-promise ש-forEach פשוט מתעלם ממנו. הלולאה שלכם מסתיימת במיקרו-שניות; בקשות הרשת ממשיכות לנדוד בכוחות עצמן. אם אתם צריכים לטפל בשגיאות לפי הסדר, או אם אתם צריכים להבטיח שבקשה אחת מסתיימת לפני שהבאה מתחילה, התבנית הזו שוברת בשקט את שתי ההבטחות הללו.

החליפו אותה בלולאת 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!');

עכשיו הלולאה באמת עוצרת בכל await. הבקשה השנייה מחכה לראשונה. 'All done!' מודפס רק אחרי שהכל הסתכם.

השתמשו ב-for...of כשסדר הפעולות חשוב — העלאת קבצים אחד אחד כדי לכבד מגבלות קצב (rate limits), כתיבת שורות בבסיס נתונים בסדר מסוים, או שרשור קריאות API שבהן הבקשה הבאה זקוקה לנתונים מהתגובה הקודמת. אם אתם באמת רוצים ביצוע מקבילי, אל תנסו "למחוות" עם forEach. השתמשו ב-Promise.all בצורה מפורשת כדי שהכוונה שלכם תהיה גלויה למפתח הבא. אך לעולם אל תערבבו await ו-forEach בציפייה להתנהגות סינכרונית. זה לא יקרה.

השתמשו ב-Promise.allSettled כש"אפס" אינו תשובה אפשרית

Promise.all הוא ישר מבחינה סמנטית. תנו לו מערך של promises, והוא יחזיר מערך של תוצאות. המלכוד הוא שברגע שכל promise בודד נדחה (rejects), כל התהליך נדחה באופן מיידי. כל שאר ה-promises הממתינים נשארים להסתיים בכוחות עצמם, אך אתם מאבדים גישה לתוצאות שלהם. בסביבת ייצור (production), ההתנהגות של "הכל או כלום" הזו היא בעייתית.

דמיינו שהאפליקציה שלכם שולפת ווידג'טים של לוח בקרה (dashboard) מארבעה שירותים עצמאיים: ניתוח תנועה, נתוני הכנסות, משוב משתמשים ובריאות השרת. ה-API של ההכנסות חווה timeout קצר. תחת Promise.all, כל לוח הבקרה שלכם זורק שגיאה. שלוש התגובות התקינות נעלמות אל התהום. המשתמש רואה ספינר (spinner), ואז מסך שגיאה, רק בגלל שרבע אחד מהנתונים לא התנהג כראוי.

Promise.allSettled נותן לכם חוזה הגיוני יותר. הוא מחכה עד שכל promise יסיים, לא משנה באיזו דרך. הערך המוחזר (resolved value) הוא מערך של אובייקטים המתארים כל תוצאה:

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);
  }
});

אף תגובה לא נזרקת. אתם מרנדרים את מה שאתם יכולים ומבודדים את הכשל. התבנית הזו חשובה בכל פעם שאתם מתמודדים עם פעולות שאינן קשורות זו לזו — שליחת התראות המונית, שליחת webhooks לצד שלישי, או ייבוא רשומות מזרמי CSV מרובים. אתם עדיין זקוקים למעקב שגיאות מרכזי, אך האפליקציה שלכם שומרת על יציבות.

הערה מעשית אחת: allSettled מחזיר את הקבוצה המלאה, כך שאתם עדיין צריכים לסנן את התוצאות ולהחליט מה המשמעות של "הצלחה חלקית" עבור הפיצ'ר שלכם. אל תתייחסו למערך המוחזר כאל נתונים "שמחים" באופן אחיד. בדקו את שדות ה-status לפני שאתם דוחפים משהו לשכבת ה-state שלכם.

הצהירו על top-level await וסיימו עם עטיפות IIFE

במשך שנים, אם רציתכם לבצע await למשהו בשורש הקובץ, הייתם עוטים אותו בתוך פונקציית async שנקראת באופן מיידי (IIFE):

(async () => {
  const config = await loadConfig();
  startServer(config);
})();

זה עובד, אבל זה רעש מיותר. top-level await, שהוא חלק מובנה ב-ES modules, מאפשר לכם לוותר על ה-boilerplate הטקסי:

const config = await loadConfig();
startServer(config);

השתמשו בזה בנקודת הכניסה (entry point) של האפליקציה שלכם או במודולי קונפיגורציה ייעודיים שבהם האתחול חייב להסתיים לפני שכל דבר אחר רץ. טעינת קבצי סביבה (environment files), הקמת מאגר חיבורים (connection pool) לבסיס נתונים, או שליפת דגלי פיצ'רים (feature flags) מרחוק הם כולם מקרים מתאימים מאוד. מכיוון ש-top-level await חוסם את ביצוע גרף המודולים — קבצים אחרים שמיבאים (importing) את הקובץ הזה יחכו שה-promise שלכם יסתיים — אתם מקבלים מצב (state) מובטח. שאר קוד הבסיס שלכם יכול לייבא את db ולדעת שהחיבור כבר פעיל.

ישנם שני מכשולים. ראשית, סביבת ההרצה (runtime) או ה-bundler שלכם חייבים לתמוך ב-ES modules. ב-Node.js, המשמעות היא שימוש בסיומות .mjs או הגדרת "type": "module" בקובץ ה-package.json שלכם. שנית, מכיוון שהמתנה ברמת המודול מעכבת כל יבואן (importer), שמרו על העבודה הממתינה (awaited) ממוקדת. שליפות רציפות כבדות בראש קובץ עזר (utility file) שמיובא לעיתים קרובות יאטו את ה-cold start של האפליקציה כולה. שמרו את ה-top-level await למשימות bootstrap אמיתיות ששאר המודולים תלויים בהן באמת.

מה באמת משתנה כשמאמצים את התבניות הללו

יכולת הניבוי היא התגמול הראשון. כשאתם קוראים לולאת for...of, אתם יודעים בדיוק מתי הבלוק שמתחתיה מסתיים. אין הבטחות (promises) רפאים שרצות מאחורי הקלעים, ואין callbacks של foreach שמתנתקים ממנהלי השגיאות (error handlers) שלכם. זרימת הבקרה (control flow) שלכם תואמת למבנה הקוד שעל המסך.

עמידות (Resilience) היא השלב הבא. Promise.allSettled מאלץ אתכם לחשוב על כישלון חלקי במקום לקוות שכל מערכת חיצונית תישאר מושלמת. תוכנת פרודקשן אינה בינארית. חלק מה-endpoints יתנהגו בצורה לא יציבה. חלק מקריאת הקבצים ייתקלו בשגיאות הרשאות. תכנון מול המציאות של כישלונות מפוזרים שומר על האפליקציה שלכם יציבה, מבלי להסתיר נתונים לגיטימיים מתחת לשטיח.

בהירות היא האלמנט שקושר הכל יחד. for...of נקרא כמו התקדמות בשפה פשוטה. allSettled מצהיר על כוונתו כבר בשמו. top-level await מסיר עטיפות IIFE מסתוריות, כך שקבצי הכניסה (entry files) שלכם מתחילים בלוגיקה עסקית במקום באקרובטיקה תחבירית. המהנדס הבא שייגע בקובץ — בין אם זה אתם בעוד שישה חודשים או חבר לצוות שעובד תחת דדליין — יודה לכם.

תובנה מעשית

אל תתייחסו ל-async/await כאל פתרון גלובלי שפשוט מפזרים על קוד קיים. בצעו ביקורת (audit) בפרויקטים הנוכחיים שלכם עבור שלושת ה-anti-patterns הספציפיים הללו. חפשו await בתוך בלוקים של forEach והחליפו אותם ב-for...of או ב-Promise.all מכוון. עברו על כל Promise.all שמתקשר עם שירותים חיצוניים ושאלו את עצמכם האם כישלון בודד באמת אמור להכשיל את כל הפעולה; אם לא, עברו ל-Promise.allSettled וטפלו בתוצאות המעורבות. לבסוף, הסירו את ה-async IIFEs מנקודות הכניסה של ה-ES modules שלכם ותנו ל-top-level await לנהל את רצף ה-bootstrap ישירות. אלו שינויים מכניים קטנים, אך יחד הם הופכים סקריפטים אסינכרוניים שבירים לקוד שבאמת ניתן לסמוך עליו.