|

JavaScript Async Programming for QA Engineers: Callbacks, Promises, and Async/Await

JavaScript async programming is the single topic that separates QA engineers who copy-paste test scripts from SDETs who architect stable automation frameworks. I have interviewed 300+ candidates, and I can tell within five minutes whether someone truly understands how callbacks, promises, and async/await behave under load. Most do not. They memorize syntax, but they freeze when I ask what happens if you forget an await inside a loop, or why Promise.all fails fast while Promise.allSettled does not.

In the previous article on JavaScript basics for testers, we covered variables, data types, and operators. This article goes deeper. We will move from synchronous code to the three eras of JavaScript async programming: callbacks, promises, and async/await. By the end, you will have production-ready code patterns you can run in your Playwright suite today, plus the interview answers that actually impress hiring managers in Bengaluru, Hyderabad, and remote-first product companies.

Table of Contents

Contents

Why Test Automation Lives and Dies by Async Code

Every modern test automation framework is async. Playwright, Selenium WebDriver, Cypress, Supertest, axios — every network call, every DOM query, every browser event returns a promise or accepts a callback. If you do not control the timing, you get flaky tests. Flaky tests destroy trust in CI pipelines. Destroyed trust leads to ignored builds. Ignored builds mean bugs reach production.

Here is a number that should wake you up: the async utility library on npm sees 381.8 million monthly downloads. The p-limit package, which controls promise concurrency, sees 1.04 billion monthly downloads. Even the humble axios HTTP client, which is promise-based under the hood, pulls 444.9 million monthly downloads. These are not frontend frameworks. These are infrastructure utilities that power the test runners, CI scripts, and API clients you use every single day.

When I review a candidate’s GitHub portfolio, the first thing I check is whether their test code mixes sync and async logic without understanding the event loop. I once saw a Playwright suite with 340 test cases where the author wrapped every async call in a setTimeout because they could not figure out why elements were not ready. The suite took 47 minutes to run. After we rewrote it with proper async/await and Promise.all, it dropped to 9 minutes. That is not a minor optimization. That is the difference between a pipeline that runs on every commit and one that runs once a day because engineers are afraid of it.

What Is Asynchronous JavaScript?

JavaScript is single-threaded. One call stack. One heap. No parallelism at the language level. But the browser and Node.js runtime give us APIs — timers, network requests, file system operations — that execute outside the main thread. When the operation completes, its callback enters a queue. The event loop pushes that callback onto the stack when the stack is empty.

Think of it like a restaurant kitchen. The chef (the main thread) can only cook one dish at a time. But the oven (the runtime API) can bake bread while the chef chops vegetables. When the bread is ready, a waiter (the event loop) brings it to the chef’s station. The chef does not stand and stare at the oven. That would block the entire kitchen.

In test automation, this matters because every browser interaction is an async operation. When you call page.click() in Playwright, you are not clicking a DOM element directly. You are sending a message to a browser process over a WebSocket. The response comes back asynchronously. If you treat it like a synchronous function, you move to the next line before the click actually happened. Your assertion fails, and you waste three hours debugging a selector that was never the problem.

The Callback Era — How We Started

Before ES2015, JavaScript handled async operations with callbacks. A callback is simply a function passed as an argument to another function, which invokes it when the async work completes.

The Classic setTimeout Example

function fetchData(callback) {
  setTimeout(() => {
    callback(null, { user: 'dev', id: 101 });
  }, 1000);
}

fetchData((error, data) => {
  if (error) {
    console.error(error);
    return;
  }
  console.log(data);
});

This pattern works, but it scales poorly. When you need to chain dependent operations, you nest callbacks inside callbacks. The indentation grows. Error handling duplicates at every level. We call it callback hell or the pyramid of doom.

The Pyramid of Doom in Real Test Code

// This is real code I found in a legacy Selenium suite
login((err, user) => {
  if (err) { done(err); return; }
  navigateToDashboard(user.token, (err, page) => {
    if (err) { done(err); return; }
    fetchOrders(page.sessionId, (err, orders) => {
      if (err) { done(err); return; }
      assertOrdersValid(orders, (err, result) => {
        if (err) { done(err); return; }
        done();
      });
    });
  });
});

By the fourth nested level, you have lost track of variable scope. Error handling is copy-pasted. If one step fails, you must manually propagate the error. It is brittle, unreadable, and impossible to unit test in isolation. Callbacks are not inherently bad — event listeners still use them — but they are the wrong abstraction for sequential async workflows.

Promises — A Contract for the Future

ES2015 introduced Promises. A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states: pending, fulfilled, or rejected. You attach handlers with .then() for success and .catch() for failure.

Creating and Consuming Promises

function fetchUser(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId <= 0) {
        reject(new Error('Invalid user ID'));
      } else {
        resolve({ id: userId, name: 'Pramod' });
      }
    }, 500);
  });
}

fetchUser(101)
  .then(user => console.log(user))
  .catch(err => console.error(err));

The immediate benefit is flattening. Instead of nesting, you chain. Errors bubble down to a single .catch(). You can return another promise from .then(), and JavaScript waits for it before moving to the next link in the chain.

Promise.all and Promise.race

One of the most powerful patterns in test automation is running independent async operations in parallel. Promise.all takes an array of promises and returns a single promise that resolves when all input promises resolve. If any promise rejects, the entire operation rejects immediately.

const [user, orders, config] = await Promise.all([
  fetchUser(101),
  fetchOrders(101),
  fetchConfig()
]);

If you need all results regardless of individual failures, use Promise.allSettled. It resolves after every promise settles, giving you an array of objects with status and either value or reason. This is critical in API testing when you want to assert on every endpoint’s behavior, not just bail on the first failure.

ES2024 introduced Promise.withResolvers(), which gives you the resolve and reject functions outside the constructor. It is already supported in Node.js 22 and modern browsers, and I use it in test utilities when I need to resolve a promise from an event handler.

The Async Library Still Matters

Even in 2026, the async npm package by Caolan McMahon maintains 28,169 GitHub stars and those 381.8 million monthly downloads. It provides utility functions like async.mapLimit, async.waterfall, and async.parallel that wrap callback-based APIs in controlled concurrency patterns. If you maintain legacy Node.js test suites or work with older database drivers, you will still encounter it. Understanding how it maps to modern promises makes you a better debugger.

Async/Await — Writing Synchronous-Looking Async Code

ES2017 gave us async and await. This is not a new runtime mechanism. It is syntactic sugar over Promises. An async function always returns a Promise. The await keyword pauses execution of the async function until the awaited Promise settles, then resumes with the resolved value.

The Basic Syntax

async function getUserProfile(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  return { user, orders };
}

getUserProfile(101)
  .then(profile => console.log(profile))
  .catch(err => console.error(err));

The visual clarity is undeniable. You read this top-to-bottom like synchronous code. But do not let the syntax fool you. The function still yields control back to the event loop at every await. Other code runs during those gaps. If you are mutating shared state, you can still have race conditions.

Error Handling with try/catch

The real advantage of async/await emerges when you handle errors. Instead of chaining .catch(), you use ordinary try/catch/finally blocks. This makes resource cleanup intuitive.

async function runTestWithCleanup(page) {
  let testData = null;
  try {
    testData = await createTestData();
    await page.goto('/dashboard');
    await page.click('[data-testid="submit"]');
    const confirmation = await page.locator('.success').textContent();
    expect(confirmation).toContain('Order placed');
  } catch (error) {
    await page.screenshot({ path: `failure-${Date.now()}.png` });
    throw error;
  } finally {
    if (testData) {
      await cleanupTestData(testData.id);
    }
  }
}

This pattern is what I teach in my 90-Day SDET Blueprint. Every test should clean up after itself. A failed test that leaves orphaned database rows is worse than no test at all.

Sequential vs Parallel Execution

Here is the trap every junior engineer falls into. They write async/await inside a loop, creating accidental sequential execution:

// Slow: each request waits for the previous one
for (const userId of userIds) {
  await sendNotification(userId);  // 100ms each × 100 users = 10 seconds
}

If the operations are independent, map them to promises and await Promise.all:

// Fast: all requests start immediately
const notifications = userIds.map(id => sendNotification(id));
await Promise.all(notifications);  // ~100ms total

In a Playwright test suite, this difference determines whether your job finishes in 8 minutes or 47 minutes. I covered this exact optimization in my article on Playwright sharding and Docker. Sharding distributes tests across machines, but parallel promise execution distributes work across the event loop on a single machine. You need both.

Top-Level Await in Test Config Files

Modern Node.js and TypeScript support top-level await in ES modules. This means your playwright.config.ts can fetch dynamic configuration directly at the root level instead of wrapping everything in an async function.

import { defineConfig } from '@playwright/test';
import { fetchTestEnvironments } from './lib/config';

const environments = await fetchTestEnvironments();

export default defineConfig({
  projects: environments.map(env => ({
    name: env.name,
    use: { baseURL: env.url },
  })),
});

I use this pattern to pull environment URLs from a central registry at runtime. It eliminates hardcoded baseURL values and keeps test suites aligned with infrastructure changes. Just remember that top-level await only works in ES modules, not CommonJS, so you need "module": "ESNext" in your tsconfig.json.

Real Async Patterns in Playwright Test Automation

Playwright is built entirely on promises. Every locator method, every page action, every assertion is async. If you come from a Selenium background where some drivers used blocking calls, this feels foreign for the first week. But once you internalize the pattern, you write tests that are more stable and faster than anything you could build with explicit waits.

Auto-Waiting Is a Promise Under the Hood

When you write await page.click('button'), Playwright does not click immediately. It runs up to 16 built-in actionability checks across multiple retry loops. It waits for the element to be attached, visible, stable, enabled, and not obscured by another element. Each check is a micro-promise resolved by the browser protocol. You do not see the complexity, but you must respect the interface by using await.

// This will fail or act unpredictably
page.click('button');        // promise created, but not awaited
await page.fill('input', 'x'); // this runs before the click finishes

The forgotten await is the #1 bug in beginner Playwright code. The promise is created, but the engine moves on. Your next line executes against a page state that has not updated yet. The test fails with a timeout or a stale element reference, and you blame the framework instead of your own timing.

Parallel API Calls in Test Setup

In integration tests, you often need to seed multiple entities before the UI test begins. Instead of sequential awaits, use Promise.all:

async function seedTestData() {
  const [user, product, warehouse] = await Promise.all([
    createUser(),
    createProduct(),
    createWarehouse()
  ]);
  return { user, product, warehouse };
}

This cut my own setup time by 62% in a recent Tekion project. Three independent POST requests that each took 400ms now finish in 420ms instead of 1,200ms.

Custom Fixtures with Async Setup

Playwright’s fixture system is async by design. You can override built-in fixtures or create custom ones that perform async initialization:

import { test as base } from '@playwright/test';

const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('#email', process.env.TEST_USER!);
    await page.fill('#password', process.env.TEST_PASS!);
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');
    await use(page);
    await page.evaluate(() => localStorage.clear());
  },
});

export { test };

The use(page) call yields the fixture to the test. After the test finishes, cleanup code runs. This is impossible to implement cleanly with callbacks and painful with raw promises. Async/await makes the flow obvious.

The 5 Async Mistakes That Fail SDET Interviews

In my Complete SDET Interview Guide, I list async programming as one of the top three knowledge gaps I see. Here are the five mistakes that instantly downgrade a candidate from senior to mid-level in my scoring rubric:

  1. Forgetting await inside a loop. Writing arr.forEach(async item => { await process(item); }) and expecting sequential completion. forEach does not await the callbacks. Use for...of for sequential or Promise.all(arr.map(...)) for parallel.
  2. Not returning promises from helper functions. A helper that creates a promise but does not return it is a silent failure. The caller moves on, unaware that async work is still pending.
  3. Catching errors but swallowing them. An empty catch block that logs and does not re-throw hides real failures. In test code, a swallowed promise rejection means a green build with broken assertions.
  4. Using Promise.all when Promise.allSettled is safer. In data-driven API tests, if one row fails, Promise.all aborts the entire batch. You lose visibility into whether the other 49 rows passed. Promise.allSettled preserves every result.
  5. Mixing callbacks and promises in the same function. Modern JavaScript should not look like a frankenstein of .then() chains and nested callbacks. Pick one abstraction and stick to it.

I ask candidates to debug a snippet containing mistake #1 in almost every interview. Only 30% catch it immediately. The rest spend ten minutes blaming the test framework. That ten minutes costs them the offer.

What Indian Hiring Managers Actually Test in 2026

The JavaScript async programming question is not academic in India. It is a filter. Service-based companies like TCS, Infosys, and Wipro will ask you to explain the difference between callbacks and promises in a ten-minute HR screening. Product companies like Razorpay, Zerodha, and Tekion will give you a broken async function and ask you to fix it on a shared IDE.

Here is the salary reality. In 2026, a manual tester in Bangalore earns ₹4.5–7 LPA. An SDET who can debug async race conditions in a Playwright suite earns ₹18–35 LPA. A staff engineer who architects parallel test execution with Promise pools and custom async fixtures commands ₹40–65 LPA. The gap is not frameworks. It is fundamentals.

Hiring managers specifically look for three signals:

  • Can you explain the event loop? Not recite a blog post. Can you trace what happens when a network response returns while a for loop is running?
  • Have you optimized real test suites? Everyone claims they improved performance. I ask for the before-and-after numbers. “I replaced sequential awaits with Promise.all and cut setup time from 1,200ms to 420ms” is an answer I remember.
  • Do you know when NOT to use async/await? Senior engineers know that CPU-bound work inside an async function still blocks the event loop. They move heavy computation to worker threads or separate processes.

If you are preparing for SDET roles in 2026, study the Complete Playwright + TypeScript Skills Checklist. Async programming is the foundation of every item on that list.

Key Takeaways

  • JavaScript async programming is not optional for test automation. Every framework you use depends on it.
  • Callbacks were the original pattern, but they create unreadable pyramids and duplicate error handling. You will see them in legacy code, but you should not write new code with them.
  • Promises introduced flat chaining and centralized error handling with .catch(). Promise.all enables parallel execution; Promise.allSettled preserves every result.
  • Async/await is syntactic sugar over Promises. It makes code readable, but it does not change the underlying event loop mechanics. You still need await on every async call.
  • Parallel promise execution is the single biggest optimization lever in test automation. Replacing sequential loops with Promise.all can cut setup time by 60% or more.
  • The five interview killers are: unawaited loops, missing returns, swallowed errors, wrong promise combinator, and mixing callbacks with promises.
  • In the Indian market, async mastery separates ₹7 LPA manual testers from ₹35 LPA SDETs. Product companies test it rigorously.

FAQ

Is async/await faster than promises with .then()?
No. They compile to the same promise machinery. The performance is identical. The difference is readability and error handling. Async/await wins on both counts.

Can I use async/await in Node.js test scripts?
Yes. Node.js has supported async/await natively since version 7.6 in 2017. Every current LTS version handles it without flags or transpilers. If your organization still runs Node.js 14, upgrade immediately — it reached end-of-life in April 2023.

What is the difference between Promise.all and Promise.allSettled?
Promise.all resolves when every input promise resolves, but it rejects immediately if any single promise rejects. Promise.allSettled waits for every promise to either resolve or reject, then returns an array of status objects. Use Promise.all when every operation must succeed, and Promise.allSettled when you need to inspect every result individually.

Why does my Playwright test fail even though I used await?
Check three things. First, ensure you awaited every promise-creating call, not just the first one. Second, confirm that the page action you await is the one that actually triggers the state change. Third, verify that you are not racing against a navigation or reload that invalidates your locator.

Should I learn callbacks if I only work with modern frameworks?
You should understand them enough to read legacy code and debug older npm packages. You should not write new callback-based automation code. Even event-driven APIs in modern Node.js usually return promises or expose both interfaces.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.