async/await
Question Variations
- "What is the difference between `async/await` and raw Promises?"
- "How does the event loop handle an `await` expression?"
- "What happens if you don't `await` a Promise in a loop?"
- "Can you use `await` outside of an `async` function? (Top-level await)"
Why This Is Asked
Interviewers use this to gauge whether you understand asynchronous programming beyond syntax — specifically how the runtime schedules work, what happens to the thread during an await, and whether you can reason about concurrency pitfalls.
Key Concepts
async/awaitis syntactic sugar over Promises (JS) or Tasks (.NET), not a threading mechanism- The
awaitkeyword yields control back to the caller and resumes after the awaited operation completes - Does not create new threads — it frees the current thread to do other work during I/O
- Error handling via try/catch wraps rejected promises or faulted tasks
- Sequential vs concurrent awaiting (
await a; await bvsawait Promise.all([a, b]))
Question Variations
- “What is the difference between
async/awaitand raw Promises?” - “How does the event loop handle an
awaitexpression?” - “What happens if you don’t
awaita Promise in a loop?” - “Can you use
awaitoutside of anasyncfunction? (Top-level await)”
Answers by Technology
+ Add VariantExpected Answer (.NET 10 / C# 14)
In .NET, async/await is built on the Task Parallel Library (TPL). An async method returns a Task or Task<T>, and the await keyword suspends execution until the awaited task completes — without blocking the calling thread.
The compiler transforms async methods into a state machine. Each await is a suspension point. When the awaited task completes, the continuation is scheduled on the captured SynchronizationContext (for UI apps) or the thread pool (for ASP.NET Core).
Why It Matters
Asynchronous programming is essential for scalability in ASP.NET Core and responsiveness in UI applications (WPF/MAUI). By not blocking threads during I/O operations, a single server can handle thousands of concurrent requests with a relatively small thread pool. Getting this wrong (e.g., “Sync-over-Async”) leads to Thread Pool Starvation and application deadlocks.
Example Code
public async Task<string> GetDataAsync()
{
// Thread is released back to the pool during the HTTP call
var response = await _httpClient.GetStringAsync("https://api.example.com/data");
// Execution resumes here after the I/O completes.
// In ASP.NET Core, this could be any thread pool thread.
return response;
}
Common Mistakes
- Blocking on async code: Calling
.Resultor.Wait()on a task causes deadlocks in environments with aSynchronizationContext(like legacy ASP.NET or WPF). - Using
async void: Except for event handlers,async voidshould be avoided as exceptions crash the process and the caller cannot await completion. - Forgetting
ConfigureAwait(false)in library code: This can cause deadlocks when the library is consumed by a UI application that has a single-threadedSynchronizationContext.
Follow-up Questions
- What is the difference between
Task.Runandasync/await? (Answer:Task.Runoffloads CPU-bound work to a thread pool thread;async/awaitis for I/O-bound work that frees the thread during waiting). - What happens if you
awaitan already-completed task? (Answer: The method continues execution synchronously without suspending or creating a state machine continuation).
References
Expected Answer (Java 26)
While Java does not have the async and await keywords, it provides powerful alternatives for non-blocking programming.
- CompletableFuture (Java 8+): A reactive-style API for chaining asynchronous tasks using
thenApply,thenAccept, andallOf. - Virtual Threads (Project Loom / Java 21/26): The modern solution. Instead of non-blocking syntax, Java now supports “Virtual Threads” that are extremely lightweight. You can write simple, blocking-style code, and the JVM automatically yields the underlying carrier thread during I/O.
- Future: The older, limited interface for representing the result of an asynchronous computation.
Why It Matters
Java’s move toward Virtual Threads represents a shift away from the complexity of reactive programming (async/await style). It allows developers to use the standard “thread-per-request” model while achieving the scalability of asynchronous I/O.
Code Example
// CompletableFuture (Legacy Async/Await style)
CompletableFuture.supplyAsync(() -> fetchData())
.thenApply(data -> process(data))
.thenAccept(result -> System.out.println(result));
// Virtual Threads (Java 21/26 approach)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
var data = fetchData(); // Looks blocking, but is actually non-blocking
var result = process(data);
System.out.println(result);
});
}
Common Mistakes
- Blocking Platform Threads: Calling blocking I/O on a standard thread pool can exhaust the pool.
- ThreadLocal with Virtual Threads: Because virtual threads are cheap, creating millions of them while using large
ThreadLocalobjects can lead to high memory usage.
Follow-up Questions
- What is a Carrier Thread? (Answer: The physical OS thread that a Virtual Thread is scheduled onto).
- Why doesn’t Java add async/await? (Answer: The designers preferred Virtual Threads because they don’t require changing existing APIs or splitting the ecosystem into “async” and “sync” functions).
Expected Answer (PHP 8.5)
PHP is traditionally synchronous (one process per request). However, it has evolved to support concurrency:
- Fibers (PHP 8.1+): The primitive for “green threads.” Fibers allow pausing execution without blocking the entire process. They are not “async/await” in the JS sense but provide the foundation for it.
- ReactPHP / Amp: Libraries that provide event loops and Promises, similar to Node.js.
- Guzzle Promises: Often used for concurrent HTTP requests.
- Swoole / RoadRunner: High-performance application servers that replace standard FPM to enable true asynchronous PHP.
Why It Matters
PHP’s “shared nothing” architecture makes it simple but historically limited for I/O bound tasks (like calling 5 APIs at once). Understanding Fibers and Event Loops allows developers to optimize high-traffic web applications.
Code Example
// Using Guzzle for concurrent requests (Common PHP Async pattern)
$promises = [
'user' => $client->getAsync('/user/1'),
'posts' => $client->getAsync('/user/1/posts'),
];
// Wait for all responses to settle
$results = GuzzleHttp\Promise\Utils::unwrap($promises);
Common Mistakes
- Blocking the loop: In an event-loop environment (ReactPHP), using a blocking function like
sleep()orfile_get_contents()stops the entire server for all users. - Assuming multi-threading: PHP Fibers are coroutines, not threads. They run on a single CPU core.
Follow-up Questions
- What is the difference between a Fiber and a Thread? (Answer: Fibers are managed by the application/runtime and are cooperative; Threads are managed by the OS and are preemptive).
- How does RoadRunner improve PHP performance? (Answer: It keeps the application in memory between requests, avoiding the overhead of booting the framework for every hit).
Expected Answer
In JavaScript, async/await is syntactic sugar over Promises and the event loop. An async function always returns a Promise. The await keyword pauses execution of the async function until the Promise settles, then resumes with the resolved value.
Under the hood, the engine uses microtasks. When await is hit, the function suspends and a microtask is queued. After the current call stack clears and the awaited Promise resolves, the microtask runs the continuation.
Why It Matters
Asynchronous programming is the backbone of high-performance Node.js and responsive web applications. Without async/await, developers were forced into “Callback Hell” or complex Promise chains that were difficult to read and debug. Understanding the event loop and microtask queue is critical for avoiding UI freezes and race conditions.
Example Code
Basic Usage
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
}
Sequential vs Concurrent
// Sequential — second fetch waits for first (slow)
const a = await fetchA();
const b = await fetchB();
// Concurrent — both requests start immediately (fast)
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Common Mistakes
- Sequential awaiting when concurrent is possible: Writing
await a(); await b();when the two calls are independent. This doubles the latency unnecessarily. - Swallowing errors: Forgetting
try/catchor.catch()on Promises, leading to unhandled rejections that silently fail or crash the Node.js process. - Mixing
awaitwith.then()chains: Creates hard-to-read code and can introduce subtle ordering bugs.
Follow-up Questions
- What is the difference between microtasks and macrotasks? (Answer: Microtasks (Promises,
queueMicrotask) run before macrotasks (setTimeout, I/O callbacks). The microtask queue is fully drained before the next macrotask runs). - How do you limit concurrency when awaiting many Promises? (Answer: Use
Promise.allSettledwith chunking, or a library likep-limitto control the concurrency pool size).
References
Expected Answer (Python 3.14)
asyncio provides cooperative concurrency, primarily for I/O-bound work. An async def call creates a coroutine object. Awaiting it runs it until it waits; creating a task schedules it so the event loop can interleave it with other ready tasks.
Use asyncio.gather() when independent operations should run concurrently and their results are all needed. Avoid blocking calls such as time.sleep() or a CPU-heavy loop in the event-loop thread; use an asynchronous library, an executor, or another process where appropriate.
import asyncio
async def fetch(label, delay):
await asyncio.sleep(delay) # Simulates non-blocking I/O.
return label
async def main():
first, second = await asyncio.gather(
fetch("first", 0.1),
fetch("second", 0.1),
)
assert (first, second) == ("first", "second")
asyncio.run(main())
Why It Matters
Proper asynchronous design lets one process handle many concurrent I/O operations efficiently. Blocking the loop, however, increases latency for every other request or task sharing it.
Common Mistakes
- Calling an async function without awaiting or scheduling it: This creates a coroutine that may never execute.
- Using synchronous blocking I/O in a coroutine: It prevents the event loop from running other tasks.
- Assuming
asynciomakes CPU work parallel: It is concurrent on one event-loop thread unless work is moved elsewhere.
Follow-up Questions
- What does task cancellation raise at an await point? (Answer: Usually
asyncio.CancelledError, which should normally be allowed to propagate after cleanup.) - When might a semaphore be useful with
asyncio? (Answer: To bound concurrent calls to a rate-limited or resource-constrained service.)
Expected Answer (Rust 1.97.1)
An async fn returns a future, a value representing work that can make progress when polled. The future is lazy: an executor or runtime must poll it. At an .await point where the awaited future is pending, the current future yields so the executor can run another ready task.
The Rust standard library supplies the Future abstraction, while runtimes such as Tokio provide an executor and asynchronous I/O integrations. Do not block an async worker with synchronous I/O, std::thread::sleep, or long CPU work; use an async operation or move blocking work to an appropriate thread pool.
async fn fetch_label(id: u32) -> String {
format!("item-{id}")
}
async fn build_message() -> String {
let first = fetch_label(1).await;
let second = fetch_label(2).await;
format!("{first}, {second}")
}
This function needs to be run by an executor; the example intentionally shows only language-level async code and does not choose a runtime.
Why It Matters
Async Rust can handle many concurrent I/O operations efficiently while preserving memory safety. Correctly separating asynchronous I/O from blocking work prevents latency spikes and executor starvation.
Common Mistakes
- Calling an async function without polling or awaiting its future: The work does not run merely because the future was created.
- Assuming
asyncautomatically creates a new thread: It enables cooperative concurrency; runtime scheduling choices determine threads. - Blocking inside an async task: Other futures sharing that executor worker cannot progress.
Follow-up Questions
- Why do async Rust functions often need
Sendfutures? (Answer: Multi-threaded executors may move tasks between worker threads.) - What is a common response to CPU-heavy work? (Answer: Use a dedicated blocking pool or another execution strategy outside the async worker.)
Expected Answer (C++23)
C++ coroutines are a language facility for suspending and resuming a function. co_await awaits an awaitable, co_yield produces a yielded value, and co_return completes the coroutine. Unlike JavaScript or C#, the standard language feature does not provide a built-in executor, task type, or asynchronous I/O runtime; a library defines the coroutine return type and scheduling behavior.
Calling a coroutine commonly creates a lazy task object. Work proceeds only when the task is resumed or scheduled by the chosen library. Avoid blocking executor threads with synchronous I/O or CPU-heavy work, and ensure cancellation and lifetime behavior are part of the task API.
// Illustrative coroutine type supplied by an async library.
task<int> fetch_total() {
const int first = co_await fetch_value(1);
const int second = co_await fetch_value(2);
co_return first + second;
}
The exact task and fetch_value APIs depend on the selected coroutine library or application framework.
Why It Matters
Coroutines can express high-concurrency I/O flows without deeply nested callbacks. Understanding the separation between language syntax and runtime scheduling prevents incorrect assumptions about threads, execution, and cancellation.
Common Mistakes
- Assuming
co_awaitsupplies an async runtime: C++ provides coroutine syntax; a library supplies task types and scheduling. - Treating coroutine creation as guaranteed execution: Many task types are lazy until explicitly awaited or scheduled.
- Capturing references across suspension without lifetime guarantees: Referenced objects may be destroyed before the coroutine resumes.
Follow-up Questions
- What does
co_returndo? (Answer: It completes the coroutine and supplies its result through the coroutine promise.) - Why should a coroutine task API define cancellation? (Answer: The language does not prescribe cancellation semantics or resource cleanup policy.)
Expected Answer (Express 5.1.0 / Node.js 18+)
In Express, an async route handler returns a Promise. await pauses that handler’s continuation until the awaited Promise settles; it does not block Node.js from serving other events. In Express 5, if the Promise returned by a route or middleware rejects, Express calls the error chain automatically. This makes ordinary asynchronous handlers concise: return or await the work, then send one response.
That guarantee covers the promise Express receives. If a handler starts detached callback work, uses a timer, or starts a Promise without returning or awaiting it, its later error is not necessarily connected to the request. Avoid fire-and-forget work in a request handler unless it has its own error handling and lifecycle. Do not mix callback completion and await for the same operation, and do not send a response before asynchronous work that determines its result completes.
Why It Matters
Correct async flow prevents unhandled rejections, double responses, and errors that bypass the application’s API error contract.
Code Example
import express, { NextFunction, Request, Response } from 'express';
const app = express();
async function findUser(id: string): Promise<{ id: string; name: string }> {
if (id === '0') throw new Error('missing');
return { id, name: 'Ada' };
}
app.get('/users/:id', async (req: Request, res: Response) => {
const user = await findUser(req.params.id);
return res.json(user);
});
app.use((_err: Error, _req: Request, res: Response, _next: NextFunction) => res.status(404).json({ error: 'not found' }));
app.listen(3000);
Common Mistakes
- Starting an async operation without awaiting or returning it: Its failure can escape Express’s error flow.
- Sending a response twice after
await: The second write causes headers-sent errors and an unreliable client result.
Follow-up Questions
- Does
awaitblock the Node.js event loop? (Answer: No; it suspends that async function while the event loop can process other work.) - Do callback errors auto-reach Express 5 error middleware? (Answer: No; callback errors must be passed to
next(err).)
Expected Answer (Koa 3.2.1 / Node.js 18+)
Koa is built around async middleware. await next() suspends the current middleware while the downstream stack runs, then resumes it during unwinding. An await of ordinary I/O suspends only that async function, allowing the Node.js event loop to process other work. Rejections from awaited work propagate to an outer Koa error boundary.
Always await or return work that determines a request’s result. Detached promises and callback errors are not reliably connected to the request lifecycle, so handle them independently or forward them through a controlled promise.
Why It Matters
Correct async control flow ensures errors reach the API boundary instead of becoming unhandled rejections or double responses.
Code Example
import Koa, { Context } from 'koa';
const app = new Koa();
async function findUser(id: string) { if (id === '0') throw new Error('missing'); return { id }; }
app.use(async (ctx: Context) => { ctx.body = await findUser(ctx.query.id as string); });
app.listen(3000);
Common Mistakes
- Starting a promise without awaiting it: Errors can escape the request boundary.
- Assuming
awaitblocks Node.js: It pauses the middleware, not the whole event loop.
Follow-up Questions
- Why wrap
await next()in a try/catch? (Answer: To centralize downstream errors.) - Does Koa use callback-style
next(err)? (Answer: No; errors propagate through thrown exceptions and rejected promises.)