Async functions
An async function always returns a promise. Inside one, await unwraps a promise: it pauses the function until the promise settles, then either produces the value or throws the reason. Those two sentences buy back the whole language. Because a rejection arrives as a thrown exception, try/catch works; because a value arrives as an expression, if, for, and return work; asynchronous code stops needing its own control flow and goes back to being code.
Everything here is built on the promises page and nothing here replaces it: an async function's caller receives an ordinary promise, and from the outside you cannot tell whether the function was written with async or with .then.
Create programs/async-functions.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertRejects,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
await unwraps, return wraps
Deno.test("await unwraps, return wraps", async () => {
async function fetchTotal(): Promise<number> {
const first = await Promise.resolve(3);
const second = await Promise.resolve(4);
return first + second;
}
assertStrictEquals(await fetchTotal(), 7);
const promise = fetchTotal();
assert(promise instanceof Promise);
assertStrictEquals(await promise, 7);
});
Check programs/async-functions.test.ts
running 1 test from ./programs/async-functions.test.ts
await unwraps, return wraps ... ok (205µs)
ok | 1 passed | 0 failed (1ms)
Inside, the awaits look like ordinary assignments and the return looks like an ordinary return; outside, the function hands back a Promise<number>, whatever the body did. Note the annotation: an async function's return type is always a promise, so you write Promise<number> even though the return statement produces a number. Returning a promise is fine too and does not nest, which is the adoption rule from the promises page's what a then callback returns decides the next promise.
five places async can go
Deno.test("five places async can go", async () => {
async function declared(): Promise<string> {
return await Promise.resolve("declaration");
}
const expressed = async function (): Promise<string> {
return await Promise.resolve("expression");
};
const arrow = async (): Promise<string> => await Promise.resolve("arrow");
const holder = {
async method(): Promise<string> {
return await Promise.resolve("object literal method");
},
};
class Container {
async method(): Promise<string> {
return await Promise.resolve("class method");
}
}
assertStrictEquals(await declared(), "declaration");
assertStrictEquals(await expressed(), "expression");
assertStrictEquals(await arrow(), "arrow");
assertStrictEquals(await holder.method(), "object literal method");
assertStrictEquals(await new Container().method(), "class method");
});
five places async can go ... ok (52µs)
Declaration, expression, arrow, object literal method, and class method. Unlike a generator, an async arrow function exists, worth noting because the generators page makes a point of the opposite. The await Promise.resolve bodies are not decoration either: an async function containing no await is a lint error on Deno, which a later step captures, so every function on this page genuinely awaits.
a rejection arrives as an exception
Deno.test("a rejection arrives as an exception", async () => {
async function handled(): Promise<string> {
try {
await Promise.reject(new Error("failed"));
return "not reached";
} catch (error) {
if (error instanceof Error) return `caught ${error.message}`;
throw error;
}
}
assertStrictEquals(await handled(), "caught failed");
// deno-lint-ignore require-await
async function failing(): Promise<never> {
throw new Error("thrown, not rejected");
}
const promise = failing();
assert(promise instanceof Promise);
const error = await assertRejects(() => promise, Error);
assertStrictEquals(error.message, "thrown, not rejected");
});
a rejection arrives as an exception ... ok (171µs)
This is the reason the feature exists. A rejected promise, awaited, throws, so the error handling you already know applies without modification: try, catch, finally, rethrowing, narrowing. One wrinkle is carried over rather than introduced, since the caught value is unknown here as everywhere, so the instanceof check is the same move the errors and exceptions page recommends in the caught value is unknown. And the reverse direction holds too. A throw inside an async function rejects its promise rather than propagating as an exception, even when the throw happens before the first await, which is why failing() handed back a promise instead of blowing up the call site, and why the reading tool is assertRejects rather than assertThrows. So an async function honours the promises page's rule about not mixing rejections with exceptions for free, which alone is a reason to prefer async for any function doing asynchronous work. The lint ignore above failing is deliberate, and the step that captures the rule explains it.
await accepts anything, and always pauses
await 0 awaits a plain number. Predict where "caller keeps going" lands:
Deno.test("await accepts anything, and always pauses", async () => {
const log: string[] = [];
async function counted(): Promise<void> {
log.push("start");
await 0;
log.push("after a plain value");
}
const running = counted();
log.push("caller keeps going");
await running;
assertEquals(log, ["start", "after a plain value", "caller keeps going"]);
});
Check programs/async-functions.test.ts
running 4 tests from ./programs/async-functions.test.ts
...
await accepts anything, and always pauses ... FAILED (8ms)
ERRORS
await accepts anything, and always pauses => ./programs/async-functions.test.ts:81:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
"start",
- "caller keeps going",
"after a plain value",
+ "caller keeps going",
]
FAILURES
await accepts anything, and always pauses => ./programs/async-functions.test.ts:81:6
FAILED | 3 passed | 1 failed (9ms)
error: Test failed
It landed in the middle. await on a non-promise gives the value straight back, and still pauses: the body stopped at await 0, control returned to the caller, and the rest of the body ran a microtask later, even though nothing asynchronous happened. Two consequences. You can await a value that might or might not be a promise without checking, which makes an API returning either easy to consume, and a thenable works too, from the promises page's a thenable is enough. And await is never free, because every one is a suspension point where other code runs, machinery the event loop page steps through, so an await on something you know is not a promise is a real cost paid for nothing. Correct the prediction and add the thenable:
Deno.test("await accepts anything, and always pauses", async () => {
const log: string[] = [];
async function counted(): Promise<void> {
log.push("start");
await 0;
log.push("after a plain value");
await { then: (ok: (v: string) => void) => ok("thenable") };
log.push("after a thenable");
}
const running = counted();
log.push("caller keeps going");
await running;
assertEquals(log, [
"start",
"caller keeps going",
"after a plain value",
"after a thenable",
]);
});
await accepts anything, and always pauses ... ok (150µs)
await outside an async function is a syntax error
Deno.test("await outside an async function is a syntax error", () => {
assertThrows(
() => new Function("function sync() { await Promise.resolve(1); }"),
SyntaxError,
"await is only valid in async functions and the top level bodies of modules",
);
assertStrictEquals(
typeof new Function("'use strict'; var await = 1; return await;"),
"function",
);
});
await outside an async function is a syntax error ... ok (207µs)
The message is unusually good: it names both legal places, inside an async function and at the top level of a module, and a later step uses the second one. There is a small precision worth having, and it separates await from yield. yield is a reserved word in all strict code, as the generators page observed with the same compile-a-string trick from the functions page, where await is reserved only inside async functions and inside modules, so the second probe accepts it as a variable name even under 'use strict'. Since everything you write in Deno is a module, await is effectively always reserved for you, and the difference only shows up in code evaluated as a script.
awaiting is shallow
map with an async callback looks like it produces strings:
Deno.test("awaiting is shallow", async () => {
const words = ["a", "bb"];
const results = words.map(async (word) => {
const length = await Promise.resolve(word.length);
return `${word}:${length}`;
});
assertEquals(results, ["a:1", "bb:2"]);
});
Check programs/async-functions.test.ts
TS2322 [ERROR]: Type 'string' is not assignable to type 'Promise<string>'.
assertEquals(results, ["a:1", "bb:2"]);
~~~~~
at file:///programs/async-functions.test.ts:127:26
TS2322 [ERROR]: Type 'string' is not assignable to type 'Promise<string>'.
assertEquals(results, ["a:1", "bb:2"]);
~~~~~~
at file:///programs/async-functions.test.ts:127:33
Found 2 errors.
error: Type checking failed.
The checker refuses, and its complaint names the actual shape: the array holds Promise<string>, not string. await pauses the function it is written in and nothing else, so an await inside a callback pauses the callback, and map collects what the callbacks return, which is promises. This is the most common async mistake, and here the type system catches it outright. Promise.all is the fix, from the promise combinators page:
Deno.test("awaiting is shallow", async () => {
const words = ["a", "bb"];
const promises = words.map(async (word) => {
const length = await Promise.resolve(word.length);
return `${word}:${length}`;
});
assert(promises[0] instanceof Promise);
assertEquals(await Promise.all(promises), ["a:1", "bb:2"]);
const kept = words.filter(
async (word) => (await Promise.resolve(word.length)) > 1,
);
assertEquals(kept, ["a", "bb"]);
});
awaiting is shallow ... ok (102µs)
The filter pin is the same mistake with the safety net gone: a predicate may return anything truthy, so an async predicate type-checks, a promise is always truthy, and the filter kept everything, including the one-letter word the condition was written to drop. forEach with an async callback compiles and does not wait, for the same reason. When a callback needs an await, reach for map plus Promise.all, where the checker at least has a chance to see the promises.
return await is caught, plain return is not
Two functions differing by one word. Predict what escapesTheTry produces:
Deno.test("return await is caught, plain return is not", async () => {
async function caughtHere(): Promise<string> {
try {
return await Promise.reject(new Error("inner"));
} catch {
return "handled inside";
}
}
// deno-lint-ignore require-await
async function escapesTheTry(): Promise<string> {
try {
return Promise.reject(new Error("inner"));
} catch {
return "never reached";
}
}
assertStrictEquals(await caughtHere(), "handled inside");
assertStrictEquals(await escapesTheTry(), "never reached");
});
Check programs/async-functions.test.ts
running 7 tests from ./programs/async-functions.test.ts
...
return await is caught, plain return is not ... FAILED (216µs)
ERRORS
return await is caught, plain return is not => ./programs/async-functions.test.ts:132:6
error: Error: inner
return Promise.reject(new Error("inner"));
^
FAILURES
return await is caught, plain return is not => ./programs/async-functions.test.ts:132:6
FAILED | 6 passed | 1 failed (2ms)
error: Test failed
The rejection went straight past the catch and killed the test. return await p and return p behave identically except inside a try, and there the difference is a bug. With await, the rejection happens inside the try and the catch sees it. Without, you return a promise and the function is finished; the promise rejects later, adopted by the caller, long after the try has been left, and the catch cannot fire because there is nothing left to catch. Note that the linter had already flagged the buggy function, since silencing require-await was the only way to write it. Correct the reading to assertRejects:
Deno.test("return await is caught, plain return is not", async () => {
async function caughtHere(): Promise<string> {
try {
return await Promise.reject(new Error("inner"));
} catch {
return "handled inside";
}
}
// deno-lint-ignore require-await
async function escapesTheTry(): Promise<string> {
try {
return Promise.reject(new Error("inner"));
} catch {
return "never reached";
}
}
assertStrictEquals(await caughtHere(), "handled inside");
const error = await assertRejects(() => escapesTheTry(), Error);
assertStrictEquals(error.message, "inner");
});
return await is caught, plain return is not ... ok (91µs)
This is why return await is worth writing even where it looks redundant. It costs one extra microtask, it makes the line safe to move into a try later, and it does not rely on a reader knowing that resolving unwraps.
top-level await
await also works with no function around it at all. Put this in a scratch file programs/top-level.ts, next to the programs/modules/ companions from the modules page:
const config = await Promise.resolve({ retries: 3 });
export const retries = config.retries;
console.log(`retries: ${retries}`);
let library;
try {
library = await import("./fast-path.ts");
} catch {
library = await import("./modules/counter.ts");
}
console.log(`fallback constant: ${library.LIGHT_SPEED}`);
Run it with deno run programs/top-level.ts:
retries: 3
fallback constant: 299792458
Two features in one file. await at the top level of a module works with no flag, no wrapper, and no main function: the module's evaluation becomes asynchronous, and any module importing it waits for it to finish before its own body runs. And a dynamic import() is a function call returning a promise, so it can sit in a try, be conditional, or be computed, where a static import is declarative and hoisted, from the modules page: the missing fast-path.ts rejected, the catch took the fallback, and the constant from counter.ts came through. The cost of the first feature is real, because a module with a top-level await delays every module that imports it, and Deno's linter has a no-top-level-await rule, off by default, for code that must target platforms without the feature. Delete the scratch file, since its deliberately missing module would fail a deno check of the folder.
the body starts now, the promise settles later
Deno.test("the body starts now, the promise settles later", async () => {
const log: string[] = [];
async function starting(): Promise<void> {
log.push("body");
await Promise.resolve();
}
log.push("before");
const pending = starting().then(() => {
log.push("settled");
});
log.push("after");
await pending;
assertEquals(log, ["before", "body", "after", "settled"]);
});
the body starts now, the promise settles later ... ok (39µs)
The same measurement the promises page made in the body starts now, the settlement arrives later, now with async syntax, and the answer is unchanged: calling an async function runs its body immediately, synchronously, up to the first await, and only then does control return to you. Which means the order in which you call async functions is the order their work starts, and that is what makes the next step's fix work.
an await in a loop body is sequential
Deno.test("an await in a loop body is sequential", async () => {
const startOrder: string[] = [];
async function tracked(id: string): Promise<void> {
startOrder.push(`start ${id}`);
await Promise.resolve();
startOrder.push(`end ${id}`);
}
for (const id of ["a", "b"]) {
await tracked(id);
}
assertEquals(startOrder, ["start a", "end a", "start b", "end b"]);
startOrder.length = 0;
await Promise.all(["a", "b"].map((id) => tracked(id)));
assertEquals(startOrder, ["start a", "start b", "end a", "end b"]);
});
an await in a loop body is sequential ... ok (79µs)
The loop is the most common performance mistake in asynchronous JavaScript, and the log names it exactly: b does not start until a has finished. Sometimes that is what you want, because the second call needs the first result, or because the far end will complain about ten thousand connections. Often it is accidental, and the fix is to start everything and then wait, which is Promise.all over a map, with the second log showing both starts before either end. The linter has a no-await-in-loop rule for this, off by default, correctly, since a sequential loop is frequently deliberate.
async with no await is a lint error
The rule this page has been stepping around, captured. Write an awaitless async function in a scratch file programs/looks-modern.ts:
export async function looksModern(): Promise<number> {
return 1;
}
error[require-await]: Async function 'looksModern' has no 'await' expression or 'await using' declaration.
--> programs/looks-modern.ts:1:8
|
1 | export async function looksModern(): Promise<number> {
| ^^^^^
= hint: Remove 'async' keyword from the function or use 'await' expression or 'await using' declaration inside.
docs: https://docs.deno.com/lint/rules/require-await
Found 1 problem
Checked 1 file
require-await is on by default, and it has already earned its keep on this page, where the one function it flagged, escapesTheTry, was exactly the function whose missing await was the bug. The useful thing about the rule is that the fix is a question rather than an edit: either the function has no asynchronous work and should not be async, or it has some and you forgot to await it. Marking a function async is not free decoration, because it changes the return type from T to Promise<T>, which every caller now has to handle. Delete the scratch file.
started, deliberately not awaited
Deno.test("started, deliberately not awaited", async () => {
let backgroundRuns = 0;
async function recordSomething(): Promise<void> {
await Promise.resolve();
backgroundRuns++;
}
void recordSomething();
assertStrictEquals(backgroundRuns, 0);
await Promise.resolve();
assertStrictEquals(backgroundRuns, 1);
async function loadRow(id: number): Promise<string> {
return await Promise.resolve(`row ${id}`);
}
async function loadRows(ids: number[]): Promise<string[]> {
return await Promise.all(ids.map((id) => loadRow(id)));
}
assertEquals(await loadRows([1, 2]), ["row 1", "row 2"]);
});
started, deliberately not awaited ... ok (54µs)
void before a call is the one genuinely useful thing that operator does in modern code: a bare recordSomething(); looks like a forgotten await, where void recordSomething() says the omission was a decision, and survives review. The counters prove the timing, since nothing past the function's first await had run when the test first looked, and one microtask later it had. Do it knowingly, because an unawaited promise that rejects is an unhandled rejection, which the promises page showed ends the process, so anything you fire and forget needs its own .catch. And loadRows is the practice pattern in four lines: start everything you can before awaiting anything.
In practice
- Prefer
asyncandawaitto promise chains. - Start independent work before awaiting any of it.
- Keep
return await, including where it appears redundant. - Use
voidonly when deliberately starting work you will not await, and attach a.catchto anything that can fail. - Do not mark a function
asyncunless its signature should return a promise. - Assume other code can run at every
await; a value checked before it may have changed by the time it is used.
Related
- The event loop explains what can run while a function is suspended.
- Async iteration covers results that arrive one at a time.