Async Error-Handling Anti-Patterns — Exercises¶
Category: Async Anti-Patterns → Error Handling — hands-on practice making async failures impossible to lose. Covers (collectively): Swallowed Promise Rejection · Floating Promise · Fire-and-Forget Without Logging · Forgotten
await
These are fix-it exercises, not recognition quizzes. Each one gives you a problem statement, a starting snippet (mostly JavaScript / TypeScript, some Python asyncio), acceptance criteria with hints, and a collapsible solution carrying the idiomatic fix and a why it's better note. The four anti-patterns in this category share one disease — a Promise that fails with nobody watching — and they compound: a forgotten await creates a floating Promise, a floating Promise becomes a swallowed rejection, and fire-and-forget is a swallowed rejection you chose on purpose.
How to use this file. Read the problem, try it in your editor before opening the solution, then compare. The note under each solution matters more than the diff: the goal is not "add a
.catch" but "make sure every failure has exactly one owner." Refer back tojunior.mdfor what each anti-pattern looks like andmiddle.mdfor the cures applied here.
Table of Contents¶
| # | Exercise | Anti-pattern(s) | Lang | Difficulty |
|---|---|---|---|---|
| 1 | Catch the swallowed rejection | Swallowed Rejection | JS | ★ easy |
| 2 | Fix the forgotten await | Forgotten await | TS | ★ easy |
| 3 | Anchor the floating Promise | Floating Promise | TS | ★ easy |
| 4 | Stop swallowing in Python asyncio | Swallowed Rejection | Python | ★ easy |
| 5 | Make fire-and-forget observable | Fire-and-Forget | TS | ★★ medium |
| 6 | Promise.all → allSettled for partial results | Swallowed Rejection (partial loss) | TS | ★★ medium |
| 7 | The forgotten await that hides a try/catch | Forgotten await + Swallowed | JS | ★★ medium |
| 8 | Install a global unhandledRejection net | Swallowed Rejection (last line of defense) | Node | ★★ medium |
| 9 | Capture the floating loop | Floating Promise + Forgotten await | Python | ★★ medium |
| 10 | Supervise the background task properly | Fire-and-Forget | TS | ★★★ hard |
| 11 | Move fire-and-forget to a durable queue | Fire-and-Forget (durability) | TS | ★★★ hard |
| 12 | Configure TS + ESLint no-floating-promises | meta (prevention) | config | ★★ medium |
| 13 | Judgment call: when fire-and-forget is acceptable | Fire-and-Forget (the defensible case) | TS | ★★★ hard |
| 14 | Mini-project: harden the notification service | All four | TS | ★★★★ project |
Exercise 1 — Catch the swallowed rejection¶
Anti-pattern: Swallowed Rejection · Language: JavaScript · Difficulty: ★ easy
This handler registers a success continuation but no failure one. If saveProfile rejects, the error becomes an unhandled rejection — invisible in most setups, a crashed process in strict ones.
function updateProfile(req, res) {
saveProfile(req.body).then((saved) => {
res.json(saved);
});
// No .catch — if saveProfile rejects, res never responds and the
// rejection floats off into the runtime. The client hangs until timeout.
}
Acceptance criteria - Every rejection of saveProfile produces a response (don't leave the client hanging). - The success path is unchanged. - The error is logged with enough context to debug it.
Hint: the minimal fix is a .catch; the idiomatic fix is async/await with try/catch, which keeps the success and failure paths next to each other.
Solution
Or, if you must stay in `.then` style, the rejection still needs an owner: **Why it's better.** Every outcome — success or failure — now ends in a response, so the client never hangs. The `async/await` form is preferable because the failure handler sits directly beside the code that can fail, instead of being a `.catch` you must remember to chain. The log line carries `userId` and the error, so an on-call engineer can find the offending request instead of seeing a context-free "UnhandledPromiseRejection" in the logs.Exercise 2 — Fix the forgotten await¶
Anti-pattern: Forgotten await · Language: TypeScript · Difficulty: ★ easy
This compiles and runs. It is also wrong: getUser returns a Promise<User>, not a User, and nobody waited for it.
async function greet(id: string): Promise<string> {
const user = getUser(id); // forgot await — user is Promise<User>
return `Hello, ${user.name}`; // user.name is undefined → "Hello, undefined"
}
declare function getUser(id: string): Promise<User>;
interface User { name: string }
Acceptance criteria - greet returns a string built from the resolved user, not a Promise. - Explain why TypeScript did not stop this (and what would have).
Hint: await the call. Then ask: under strict typing, should user.name have errored? It depends on how the field is accessed.
Solution
async function greet(id: string): Promise<string> {
const user = await getUser(id); // user: User
return `Hello, ${user.name}`;
}
Exercise 3 — Anchor the floating Promise¶
Anti-pattern: Floating Promise · Language: TypeScript · Difficulty: ★ easy
The cache refresh is started and immediately abandoned. If it rejects, the rejection is unhandled; if it matters, the caller has no idea whether it finished.
async function handleRequest(req: Request): Promise<Response> {
refreshCache(req.tenantId); // floating — not awaited, no .catch
return serve(req);
}
There are two legitimate intents here, and they need different fixes. Handle both.
Acceptance criteria - Case A — the refresh must finish before responding: the response waits for it, and a failure propagates. - Case B — the refresh is genuinely background: it is no longer floating (it has an error handler), even though it is not awaited. - No bare, unobserved Promise remains in either case.
Hint: "floating" means no await AND no .catch. Case A removes the float by awaiting; Case B removes it by attaching a handler and marking the intent with void.
Solution
**Case A — refresh is part of serving the request:** **Case B — refresh is fire-and-forget, but observed:** **Why it's better.** The original code reads identically to both intents but commits to neither, so the bug is "which one did the author mean?" Case A makes the dependency explicit: the response cannot be served with a stale cache, and a refresh failure surfaces. Case B keeps the work in the background — but the `.catch` means a failure is logged instead of vanishing, and the `void` keyword tells the next reader (and the linter) "yes, this is deliberately unawaited." A bare `refreshCache(...)` says none of that. Note that Case B is the *start* of doing fire-and-forget right; [Exercise 5](#exercise-5--make-fire-and-forget-observable) and [Exercise 10](#exercise-10--supervise-the-background-task-properly) go further.Exercise 4 — Stop swallowing in Python asyncio¶
Anti-pattern: Swallowed Rejection · Language: Python (asyncio) · Difficulty: ★ easy
asyncio.create_task schedules a coroutine and returns immediately. If the task raises and nobody ever awaits it (or its exception), the error is logged only when the task object is garbage-collected — long after the fact, with no context — or swallowed entirely.
import asyncio
async def on_message(msg):
asyncio.create_task(process(msg)) # task's exception is never observed
return "queued"
async def process(msg):
raise RuntimeError(f"bad message: {msg!r}")
Acceptance criteria - A failure in process is logged at the moment it happens, with the message context. - The endpoint still returns immediately (do not block on_message on process). - The task is not garbage-collected while still running.
Hint: keep a reference to the task and attach a done callback that inspects task.exception(). The done-callback is the asyncio equivalent of .catch.
Solution
import asyncio
import logging
log = logging.getLogger(__name__)
_background_tasks: set[asyncio.Task] = set()
def _supervise(task: asyncio.Task) -> None:
_background_tasks.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
log.error("background task failed", exc_info=exc)
async def on_message(msg):
task = asyncio.create_task(process(msg))
_background_tasks.add(task) # keep a strong ref so it isn't GC'd mid-flight
task.add_done_callback(_supervise) # observe the result, log any exception
return "queued"
async def process(msg):
raise RuntimeError(f"bad message: {msg!r}")
Exercise 5 — Make fire-and-forget observable¶
Anti-pattern: Fire-and-Forget Without Logging · Language: TypeScript · Difficulty: ★★ medium
Sending the welcome email should not block signup — so it is fired and forgotten. But "forgotten" is literal here: if the mail provider is down, every welcome email silently fails and no graph ever moves.
async function signup(input: SignupInput): Promise<User> {
const user = await createUser(input);
sendWelcomeEmail(user.email); // fire-and-forget: no await, no catch, no metric
return user;
}
Acceptance criteria - Signup latency is unaffected (the email is still not awaited). - A failure is logged with context. - A failure (and a success) is counted in a metric, so a provider outage is visible on a dashboard / alertable. - Nothing floats: the Promise has an owner.
Hint: wrap the background call in a small helper that logs and increments a counter in both branches, then void it. "Observable" means log + metric, not just a .catch that logs.
Solution
// A reusable "fire, but watch it land" helper.
function runBackground(
name: string,
work: () => Promise<unknown>,
): void {
void work()
.then(() => metrics.increment("background_task_ok", { name }))
.catch((err) => {
metrics.increment("background_task_error", { name });
logger.error("background task failed", { name, err });
});
}
async function signup(input: SignupInput): Promise<User> {
const user = await createUser(input);
runBackground("welcome_email", () => sendWelcomeEmail(user.email));
return user;
}
Exercise 6 — Promise.all → allSettled for partial results¶
Anti-pattern: Swallowed Rejection (partial-result loss) · Language: TypeScript · Difficulty: ★★ medium
This dashboard fans out to three independent services with Promise.all. The intent is "show whatever loads." The behavior is "if any one fails, throw away the other two and render nothing."
async function loadDashboard(userId: string): Promise<Dashboard> {
const [profile, orders, recs] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
return { profile, orders, recs };
}
Promise.all rejects on the first rejection and discards every other result — including ones that already resolved. A flaky recommendations service takes down the whole page.
Acceptance criteria - A failure in one of the three does not discard the successful ones. - The caller can tell which sections loaded and which failed (failures are not silently dropped — they are logged / surfaced as a degraded section). - Sections that load are still rendered.
Hint: Promise.allSettled never rejects — it resolves with a {status} per input. Branch on status per section; do not just ?? null the failures away, or you have swallowed the rejection a second time.
Solution
type Section<T> = { ok: true; value: T } | { ok: false };
async function loadDashboard(userId: string): Promise<{
profile: Section<Profile>;
orders: Section<Order[]>;
recs: Section<Rec[]>;
}> {
const [profile, orders, recs] = await Promise.allSettled([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
const settle = <T>(r: PromiseSettledResult<T>, name: string): Section<T> => {
if (r.status === "fulfilled") return { ok: true, value: r.value };
logger.warn("dashboard section failed", { userId, section: name, err: r.reason });
return { ok: false };
};
return {
profile: settle(profile, "profile"),
orders: settle(orders, "orders"),
recs: settle(recs, "recs"),
};
}
Exercise 7 — The forgotten await that hides a try/catch¶
Anti-pattern: Forgotten await + Swallowed Rejection · Language: JavaScript · Difficulty: ★★ medium
This looks defensive — it has a try/catch! But the await is missing, so the try block exits before charge rejects. The rejection escapes the catch entirely and floats.
async function checkout(cart) {
try {
const result = chargeCard(cart.total); // missing await
return { ok: true, id: result.txId }; // result is a Promise → txId undefined
} catch (err) {
// This never fires. The rejection happens AFTER the try block has returned.
return { ok: false, reason: err.message };
}
}
Acceptance criteria - A rejection from chargeCard is actually caught by the catch. - result.txId reads the resolved transaction id, not a property of a Promise. - Explain why the original catch was dead.
Hint: a try/catch only catches a rejected Promise if you await it inside the try. Without await, the function returns a not-yet-settled Promise and the error surfaces later, with no catch on the stack.
Solution
**Why the original `catch` was dead.** `try/catch` catches *synchronous* throws and the rejection of an *awaited* Promise. Without `await`, `chargeCard(cart.total)` synchronously returns a pending Promise — no throw happens inside the `try`, so the block completes and the function returns. When the Promise later rejects, the `try/catch` is long gone from the call stack; the rejection becomes an unhandled rejection. The missing `await` simultaneously broke the value (`result` was a Promise, so `result.txId` was `undefined`) and the error handling. This pairing — forgotten `await` *plus* a `try/catch` that gives false confidence — is one of the nastiest async bugs because the code *looks* correct. **Why it's better.** One keyword fixes both halves: `await` makes `result` the real charge result, and it puts the rejection back inside the `try` where the `catch` can convert it into a clean `{ ok: false }` outcome.Exercise 8 — Install a global unhandledRejection net¶
Anti-pattern: Swallowed Rejection (last line of defense) · Language: Node.js · Difficulty: ★★ medium
Per-call .catch is the first line of defense; bugs slip through it. A process-level handler ensures that a rejection nobody caught is at least loud, and lets you decide the process's fate deliberately instead of relying on Node's changing defaults.
Starting point: an app with no global handler. Add one.
// index.js — no safety net. A single forgotten .catch anywhere can
// either crash the process abruptly or (worse) be silently ignored,
// depending on Node version and flags.
startServer();
Acceptance criteria - An otherwise-unhandled rejection is logged with the reason and a stack. - The handler makes a deliberate decision about the process (crash-and-restart vs. continue), with a comment explaining the choice. - Note explicitly that this is a net, not a substitute for local handling.
Hint: listen for process.on("unhandledRejection", ...). The defensible default for a server behind a supervisor (systemd / Kubernetes) is to log, then exit non-zero so the orchestrator restarts a process now in unknown state.
Solution
// index.js
process.on("unhandledRejection", (reason, promise) => {
// A rejection reached the top of the stack — a real bug, not normal flow.
logger.error("UNHANDLED REJECTION — crashing for a clean restart", {
reason: reason instanceof Error ? { message: reason.message, stack: reason.stack } : reason,
});
// The process may now be in an inconsistent state (a transaction half-applied,
// a lock unreleased). Crash and let the supervisor restart a fresh process,
// rather than limp along corrupting more state. Give logs/metrics a moment to flush.
setTimeout(() => process.exit(1), 100).unref();
});
// Symmetric net for the synchronous side.
process.on("uncaughtException", (err) => {
logger.error("UNCAUGHT EXCEPTION — crashing", { message: err.message, stack: err.stack });
setTimeout(() => process.exit(1), 100).unref();
});
startServer();
Exercise 9 — Capture the floating loop¶
Anti-pattern: Floating Promise + Forgotten await · Language: Python (asyncio) · Difficulty: ★★ medium
This is meant to ping every host concurrently and report failures. It does neither: the coroutine is called but never awaited, so nothing actually runs, and any error is invisible.
import asyncio
async def ping(host):
# ... raises ConnectionError if the host is down ...
...
async def health_check(hosts):
for host in hosts:
ping(host) # creates a coroutine object, never awaited — does nothing
return "checked" # lies: nothing was checked
Calling ping(host) without await produces a coroutine object that is discarded; Python even emits a RuntimeWarning: coroutine 'ping' was never awaited — but only sometimes, and only to stderr.
Acceptance criteria - All hosts are actually pinged, concurrently (not one-at-a-time). - A failure for one host does not abort the others, and every failure is collected/reported. - No coroutine is created and dropped on the floor.
Hint: turn each coroutine into a task (or pass the coroutines to asyncio.gather), and use return_exceptions=True so one failure doesn't cancel the rest — the gather analogue of Promise.allSettled.
Solution
import asyncio
import logging
log = logging.getLogger(__name__)
async def ping(host):
... # raises ConnectionError if the host is down
async def health_check(hosts):
results = await asyncio.gather(
*(ping(h) for h in hosts),
return_exceptions=True, # one failure doesn't cancel the others
)
failures = {}
for host, result in zip(hosts, results):
if isinstance(result, Exception):
log.warning("health check failed", extra={"host": host})
failures[host] = result
return {"checked": len(hosts), "failures": failures}
Exercise 10 — Supervise the background task properly¶
Anti-pattern: Fire-and-Forget Without Logging · Language: TypeScript · Difficulty: ★★★ hard
A worker kicks off a long-running reconciliation on startup and forgets it. It is unobserved, un-cancellable, and if it throws, the process learns nothing. Turn it into a supervised task: tracked, logged, metered, and cancellable on shutdown.
class Worker {
start() {
this.reconcileForever(); // floating + fire-and-forget + un-cancellable
}
private async reconcileForever() {
while (true) {
await reconcile();
await sleep(60_000);
}
}
}
Acceptance criteria - The loop's failures are logged and counted (observable). - A transient error in one iteration does not kill the whole loop forever; a fatal/unexpected one is surfaced loudly. - On shutdown, the loop can be cancelled and stop() awaits it draining. - The task is tracked (not a bare floating Promise).
Hint: hold the task Promise in a field; drive cancellation with an AbortController; wrap each iteration in try/catch so one bad cycle logs-and-continues rather than tearing down the supervisor.
Solution
class Worker {
private task?: Promise<void>;
private readonly abort = new AbortController();
start(): void {
// Tracked (held in `this.task`) and supervised: the .catch is the
// top-level net for anything the inner loop fails to handle.
this.task = this.reconcileForever().catch((err) => {
metrics.increment("reconcile_loop_crashed");
logger.error("reconcile loop crashed", { err });
});
}
async stop(): Promise<void> {
this.abort.abort(); // signal the loop to exit
await this.task; // drain: don't return until it has actually stopped
}
private async reconcileForever(): Promise<void> {
while (!this.abort.signal.aborted) {
try {
await reconcile();
metrics.increment("reconcile_ok");
} catch (err) {
// One bad cycle is transient: log, count, and keep the loop alive.
metrics.increment("reconcile_error");
logger.warn("reconcile iteration failed; retrying next cycle", { err });
}
await sleep(60_000, this.abort.signal); // cancellable sleep
}
}
}
Exercise 11 — Move fire-and-forget to a durable queue¶
Anti-pattern: Fire-and-Forget (durability) · Language: TypeScript · Difficulty: ★★★ hard
The invoice email is contractually required — losing it is a compliance problem, not a UX wrinkle. It is currently fired-and-forgotten in-process, which means it is lost on any of: a rejection, a deploy, a crash, or a scale-down between firing and sending.
async function finalizeInvoice(invoice: Invoice): Promise<void> {
await db.markFinalized(invoice.id);
sendInvoiceEmail(invoice); // in-process fire-and-forget — lost if this pod dies
}
Logging (Exercise 5) makes failures visible, but a visible-yet-lost legally-required email is still a lost email. When the work must not be lost, observability is not enough — you need durability.
Acceptance criteria - The email work survives a process crash between "invoice finalized" and "email sent." - finalizeInvoice does not block on actually sending the email. - The enqueue is part of the same transactional boundary as the finalize, so you cannot finalize-without-enqueue or enqueue-without-finalize. - Retries/backoff are the queue's job, not inline code.
Hint: persist the intent to send in the same database transaction that finalizes the invoice (the transactional outbox pattern), then let a separate worker deliver it with retries. The handoff is durable because it is a committed DB row, not an in-memory Promise.
Solution
// 1) Enqueue durably, in the SAME transaction as the state change.
async function finalizeInvoice(invoice: Invoice): Promise<void> {
await db.transaction(async (tx) => {
await tx.markFinalized(invoice.id);
// The "outbox" row is committed atomically with the finalize.
// If the commit succeeds, the email WILL be attempted; if it rolls
// back, neither happened. No window where one occurs without the other.
await tx.insertOutbox({
type: "invoice_email",
payload: { invoiceId: invoice.id },
});
});
// Return immediately — delivery is the worker's job.
}
// 2) A separate worker drains the outbox with retries/backoff and
// at-least-once delivery. It is the only thing that touches the mail provider.
async function outboxWorker(): Promise<void> {
for (;;) {
const batch = await db.claimOutbox({ type: "invoice_email", limit: 50 });
for (const row of batch) {
try {
await sendInvoiceEmail(row.payload.invoiceId);
await db.markOutboxDone(row.id);
metrics.increment("invoice_email_sent");
} catch (err) {
// Leave the row unclaimed/retryable; the queue handles backoff.
metrics.increment("invoice_email_retry");
logger.warn("invoice email delivery failed; will retry", { id: row.id, err });
}
}
await sleep(1_000);
}
}
Exercise 12 — Configure TS + ESLint no-floating-promises¶
Anti-pattern: meta (prevention) · Language: config (TypeScript + ESLint) · Difficulty: ★★ medium
Every exercise so far fixed a bug after it was written. Floating Promises and forgotten awaits are mechanical mistakes — the right place to catch them is the linter, on every save and in CI, before they ever reach review. Configure it.
Acceptance criteria - A floating Promise (doAsync(); with no await/.catch/void) is a lint error, not a warning. - A forgotten await whose result is used is caught by the type checker. - The escape hatch for intentional fire-and-forget (void promise.catch(...)) is allowed without a lint suppression comment. - Note why these rules require type-aware linting (and the cost that implies).
Hint: use @typescript-eslint's no-floating-promises and no-misused-promises, which need parserOptions.project pointing at your tsconfig.json because they consult the type checker. Pair with tsconfig strict so Promise<T> never assigns to T.
Solution
**`tsconfig.json`** — strict typing is the foundation; it makes a forgotten `await` whose result is *used* a type error on its own (`Promise{
"compilerOptions": {
"strict": true, // includes strictNullChecks, etc.
"noImplicitAny": true,
"target": "ES2022",
"module": "NodeNext"
},
"include": ["src/**/*.ts"]
}
import tseslint from "typescript-eslint";
export default tseslint.config({
files: ["src/**/*.ts"],
languageOptions: {
parserOptions: {
// Required: these rules consult the type checker, so they need the program.
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: { "@typescript-eslint": tseslint.plugin },
rules: {
// A Promise that is neither awaited, returned, nor explicitly voided → ERROR.
"@typescript-eslint/no-floating-promises": [
"error",
{ ignoreVoid: true }, // `void p.catch(...)` is the sanctioned escape hatch
],
// Catches a Promise passed where a sync value/void is expected
// (e.g. an async function used as a non-async event handler).
"@typescript-eslint/no-misused-promises": "error",
// Optional: flag `async` functions with no `await` (the sibling "async without await").
"@typescript-eslint/require-await": "warn",
},
});
Exercise 13 — Judgment call: when fire-and-forget is acceptable¶
Anti-pattern: Fire-and-Forget (the defensible case) · Language: TypeScript · Difficulty: ★★★ hard
Not every un-awaited call is a bug. Dogma ("always await everything") is as wrong as negligence. Here is a call that is deliberately not awaited — and that is the correct decision. Your job is to justify or refute it, and state the conditions under which it stops being acceptable.
async function handlePageView(req: Request): Promise<Response> {
const page = await renderPage(req);
// Best-effort analytics: not awaited on purpose.
void recordPageView(req.path).catch((err) =>
logger.debug("analytics ping dropped", { path: req.path, err }),
);
return page; // the user must not wait on, or be failed by, an analytics ping
}
// recordPageView is idempotent (dedup'd by request id server-side) and best-effort:
// a dropped ping costs us one fuzzy data point, nothing more.
Acceptance criteria - State whether this is acceptable and why, in terms of consequences-of-loss. - List the specific properties that make it acceptable. - State what change to any of those properties would flip it back into an anti-pattern.
Solution
**Verdict: acceptable — and arguably the *correct* design here.** This is fire-and-forget done right, not the anti-pattern. The anti-pattern is fire-and-forget *without observability* and *where loss matters*; here neither condition holds. **The properties that justify it:** 1. **Loss is cheap and bounded.** A dropped analytics ping costs one imprecise data point. Nobody is mis-billed, no state is corrupted, no obligation is unmet. The *cost of loss* is the deciding variable — and here it is near zero. 2. **It must not be on the critical path.** Awaiting analytics would couple page latency (and page *failures*) to a non-essential side service. If the analytics backend is slow or down, the user should still get their page instantly. Not awaiting is therefore not just acceptable but *required* for correctness of the user-facing behavior. 3. **It is observed, not silent.** The `.catch` logs the drop (at `debug` — proportional to its low severity). It is not *floating*; it has an owner. A spike in drops is still discoverable. 4. **It is idempotent and best-effort by design.** Server-side dedup means a retry (if there were one) wouldn't double-count, and the system is explicitly designed to tolerate gaps. 5. **Intent is explicit.** The `void` keyword and the comment tell the next reader and the linter "this is deliberate," so it won't be "fixed" into an `await` by someone pattern-matching on missing keywords. **What flips it back into an anti-pattern:** - **If loss starts to matter** — e.g. analytics becomes the source of truth for usage-based *billing*. The moment a dropped event has a financial or compliance cost, you need the durable outbox of [Exercise 11](#exercise-11--move-fire-and-forget-to-a-durable-queue), not a best-effort ping. - **If you remove the `.catch`** — now it is floating, failures vanish, and you cannot even tell drops are happening. Back to the anti-pattern. - **If it stops being idempotent** — if a retried or duplicated call corrupts data, "best effort" is no longer safe to drop *or* to retry. - **If the volume of drops becomes the signal** — at scale, "1% of pings dropped" might indicate a real outage; then `debug` logging is too quiet and you need a metric/alert (promote it toward [Exercise 5](#exercise-5--make-fire-and-forget-observable)). **Why this exercise matters.** The lesson is not "fire-and-forget is fine" — it is that the *right question* is **"what does it cost if this work is lost, and can I see when it is?"** Answer that, and the correct shape falls out: await it (on the critical path), supervise-and-log it (background, loss tolerable), or persist it durably (loss unacceptable). Engineering judgment is choosing the cheapest shape that the consequences-of-loss permit — not mechanically awaiting everything.Exercise 14 — Mini-project: harden the notification service¶
Anti-pattern: all four, in one realistic module · Language: TypeScript · Difficulty: ★★★★ project
Below is a compact notification service that commits every error-handling anti-pattern in this category: a swallowed rejection, a floating Promise, a fire-and-forget with no observability, and a forgotten await. Harden it. Work in steps; do not try to fix it all in one edit.
class NotificationService {
async notifyAll(userIds: string[], message: string): Promise<string> {
// Forgotten await: loadPrefs returns a Promise, treated as the value.
const prefs = this.loadPrefs(userIds);
for (const id of userIds) {
// Floating promise: sent, abandoned. If it rejects → unhandled.
this.sendOne(id, message);
}
// Fire-and-forget audit, no logging, no metric, no durability.
this.recordAudit(userIds, message);
return "sent"; // returns before anything has actually been sent
}
private async loadPrefs(ids: string[]): Promise<Map<string, Prefs>> { /* ... */ }
private async sendOne(id: string, message: string): Promise<void> { /* may reject */ }
private async recordAudit(ids: string[], message: string): Promise<void> { /* may reject */ }
}
Acceptance criteria - Forgotten await: prefs is the resolved map, used to actually filter recipients. - Floating Promise / Swallowed Rejection: every sendOne is awaited as part of a concurrent fan-out; partial failures are collected, not dropped or thrown-on-first. - Fire-and-Forget: the audit is either properly observed (logged + metered) or made durable — pick and justify. - The return value is honest about what happened. - Each concern is testable in isolation.
Hint: fix one anti-pattern per step, keeping it compiling. Order: (1) add the await; (2) replace the floating loop with a concurrent allSettled fan-out that reports failures; (3) make the audit observable/durable; (4) make the return type honest.
Solution
interface SendReport {
attempted: number;
succeeded: number;
failed: { id: string; reason: unknown }[];
}
class NotificationService {
constructor(
private readonly outbox: Outbox, // injected: enables durable audit + testing
private readonly logger: Logger,
private readonly metrics: Metrics,
) {}
async notifyAll(userIds: string[], message: string): Promise<SendReport> {
// 1) Forgotten await → awaited; prefs is now a real Map and we use it.
const prefs = await this.loadPrefs(userIds);
const recipients = userIds.filter((id) => prefs.get(id)?.optedIn ?? false);
// 2) Floating loop → concurrent fan-out that never loses a failure.
const results = await Promise.allSettled(
recipients.map((id) => this.sendOne(id, message)),
);
const report: SendReport = { attempted: recipients.length, succeeded: 0, failed: [] };
results.forEach((r, i) => {
if (r.status === "fulfilled") {
report.succeeded++;
} else {
report.failed.push({ id: recipients[i], reason: r.reason });
this.logger.warn("notification send failed", { id: recipients[i], err: r.reason });
this.metrics.increment("notification_send_error");
}
});
// 3) Audit → durable. Losing an audit record is a compliance problem,
// so it goes through a transactional outbox, not in-memory fire-and-forget.
await this.outbox.enqueue("notification_audit", { userIds: recipients, message });
// 4) Honest return value: the caller learns exactly what happened.
return report;
}
private async loadPrefs(ids: string[]): Promise<Map<string, Prefs>> { /* ... */ }
private async sendOne(id: string, message: string): Promise<void> { /* may reject */ }
}
Summary¶
- The four error-handling anti-patterns are one disease — a Promise that fails with nobody watching — in four disguises. The cure is always the same shape: give every async failure exactly one owner.
awaitit,.catchit, orvoid-and-.catchit — but never leave it bare. A floating Promise is just a swallowed rejection that hasn't failed yet, and a forgottenawaitis how floating Promises are born.Promise.allis all-or-nothing;Promise.allSettled(andasyncio.gather(return_exceptions=True)) preserves partial results. Useallfor a unit of work,allSettledfor independent work where partial success is meaningful — and never "fix" a flakyallby silently?? null-ing failures, which swallows the rejection a second time.- Fire-and-forget exists on a spectrum keyed to consequences-of-loss: best-effort-and-logged (Exercise 5/13), supervised (Exercise 10), or durable (Exercise 11). Pick the cheapest shape the cost of loss permits — neither dogmatically
awaiteverything nor negligently drop it. - Prevention scales better than cure. A process-level
unhandledRejectionnet (Exercise 8) is the last line of defense; type-awareno-floating-promiseslinting (Exercise 12) is the first — it makes the entire bug class fail the build before it merges. - The four anti-patterns travel together (Exercise 14 shows all four in one method), so fixing the one in front of you weakens the gravity that pulls in the rest.
Related Topics¶
junior.md— what each of the four anti-patterns looks like on first sight.middle.md— the forces that create swallowed/floating/forgotten failures and the countermoves used here.senior.md— instrumenting async failures and supervising background work at scale.professional.md— event-loop, microtask queue, and structured-concurrency internals behind these fixes.find-bug.md— spot-the-async-bug snippets (critical reading practice).optimize.md— make flawed async implementations correct and parallel.interview.md— Q&A across all levels for job prep.- Async Anti-Patterns chapter — the sibling categories: Execution Shape (
awaitin a loop, Promise chain hell) and Misuse (Promise constructor,asyncwithoutawait). - Concurrency Anti-Patterns — the multi-thread sibling chapter (locks, races, deadlocks).
- Backend / Distributed Systems — retries, timeouts, durable queues, and failure handling at the network layer.
In this topic