What Is async and Why It Matters for Testing

7 min read

Up to now every line of code in this course has run instantly. Read a value, do some math, call a function — all in fractions of a millisecond. Real test code is different. Calling an API, loading a page, reading a file — these take time, and JavaScript can't freeze while it waits. Asynchronous code is how it copes. This lesson explains what async is, the analogy that makes it click, and why every line of Cypress and Playwright is async whether you've noticed or not.

JavaScript is single-threaded

JavaScript does one thing at a time. There's one chain of execution running through your code, line by line. That's not a limitation you can fix — it's how the language is designed. So when something slow comes along (an API call, a file read, a page load), JavaScript has a problem: stop everything and wait, or find another way.

The answer is async — start the slow thing, move on with other work, and come back to handle the result when it's ready.

The coffee shop analogy

Imagine ordering coffee at a busy café.

  • Synchronous behaviour: order, then stand frozen at the counter while the barista makes it. Nobody behind you can order. Nothing else gets done. You're "blocking the queue" until your latte is ready.
  • Asynchronous behaviour: order, get a receipt with your name, step aside. The barista works in the background. The next person orders. You sit down, check your phone, talk to a friend. When your name is called, you collect your coffee.

The async version isn't faster per drink — your latte still takes the same time to make. It's faster for everyone, because nothing is blocked.

JavaScript handles slow operations the same way. It hands the slow work off to the runtime (the browser, or Node.js) and keeps running. When the slow thing finishes, JavaScript runs the code you said to run "when it's done."

Synchronous code blocks

Here's a synchronous file read in Node.js — yes, this version exists, and yes, it's the wrong one to use for anything large:

const fs = require("node:fs");
console.log("before read");
const data = fs.readFileSync("config.json", "utf-8");
console.log("after read, file is", data.length, "bytes");

readFileSync does exactly what its name says — it reads, synchronously. The next line doesn't run until the read is finished. Small files? Fine. Large files, network drives, slow disks? Your whole program is frozen.

Output (assuming the file exists):

before read
after read, file is 2304 bytes

The lines run in source order, no surprises.

Asynchronous code keeps moving

The async version does not wait — it starts the timer and immediately runs the next line.

console.log("scheduling timeout");
setTimeout(() => console.log("delayed (1s)"), 1000);
console.log("after scheduling");

setTimeout schedules a callback to run later. The next line runs right now. Output:

scheduling timeout
after scheduling
delayed (1s)

Notice the order. "after scheduling" runs before "delayed (1s)" — even though setTimeout appeared first in the source. That's the most important visual demonstration of async behaviour you can do, and it surprises every beginner the first time.

Why this matters for test automation

Every interesting thing a test does involves waiting:

  • An API request travels to a server and back. Tens to hundreds of milliseconds.
  • A page loads, parses HTML, runs JavaScript, fetches images. Hundreds of milliseconds to seconds.
  • A button click triggers a re-render, the new state has to settle. Tens of milliseconds.
  • A database query returns rows. Variable, often hundreds of milliseconds.

Every Cypress command (cy.visit, cy.get, cy.click), every Playwright action (page.goto, page.locator, page.click), every fetch call in your tests — every one of them is asynchronous. The test doesn't pause-the-universe; the framework does the slow work in the background and tells your test code "now."

Understanding async isn't optional for QA work. It's the difference between writing tests that work and writing tests that fail with bizarre errors you can't explain.

The event loop, in one paragraph

Here is the simplified mental model: JavaScript runs your code on a single call stack. When it hits an async operation (fetch, setTimeout, fs.readFile), it hands the work to the runtime and keeps going. The work happens in the background. When it's done, the runtime puts the callback into a queue. Whenever the call stack is empty, JavaScript pulls the next callback off the queue and runs it. The whole loop — "run any pending code, check the queue, repeat" — is the event loop.

You don't need to understand the event loop in detail to write tests. You need to understand the consequence: code that follows an async call doesn't wait for it. The result arrives later.

Synchronous vs asynchronous timelines

Step 1 of 6

Sync — start

Line A runs (1ms). Line B (a slow read) starts.

Same total time for the slow operation in both cases. Vastly different use of that time.

⚠️ Common mistakes

  • Treating async code as synchronous. Beginners write const data = fetch(url); console.log(data); and are surprised the log shows a Promise object, not the data. The fetch hasn't finished yet — the next lessons cover how to wait for it properly.
  • Asserting on a value before it exists. A Cypress test that reads cy.get("#email"); expect(...) outside the chain is asserting on undefined. The framework's async pipeline hasn't completed by the time the assertion runs.
  • Using *Sync Node functions in test code. fs.readFileSync is fine for small fixtures, but it blocks every other operation in the same process while it runs. For anything in CI, prefer the async version.

🎯 Practice task

See async behaviour for yourself. 10-15 minutes.

  1. In your js-for-qa folder, create async-demo.js.

  2. Type:

    console.log("1: top of file");
    setTimeout(() => console.log("2: after 1 second"), 1000);
    setTimeout(() => console.log("3: after 0 seconds"), 0);
    console.log("4: bottom of file");
  3. Predict the order before running. Write your prediction in a comment.

  4. Run with node async-demo.js. Observe the actual order.

  5. The output will be 1, 4, 3, 2. Even setTimeout(..., 0) runs after the synchronous code finishes — because the callback is queued and the queue is only checked when the call stack is empty.

  6. Stretch: add a console.log("5: middle") between the two setTimeouts in your code. Predict where it appears in the output. Run again. Did your mental model survive?

The next lesson covers the original way JavaScript handled async: callbacks. You'll see why callbacks were good enough for early Node.js — and why they got replaced.

// tip to track lessons you complete and pick up where you left off across devices.