Actionable rules for writing, testing, and maintaining robust asynchronous JavaScript/TypeScript code across browser and Node.js environments.
You're already writing async JavaScript—but are you writing it well? Most developers handle promises and async/await just fine until they hit the inevitable wall: silent failures, memory leaks in React components, callback hell creeping back in, and those mysterious "unhandled promise rejection" errors that haunt production logs.
These JavaScript Asynchronous Mastery Rules transform how you write, test, and maintain async code across the entire stack. No more guessing, no more debugging mysterious race conditions at 2 AM.
Silent Promise Failures: That moment when your user data "loads" but actually failed silently because you forgot one .catch() handler. Production breaks, users see stale data, and you're left debugging ghost errors.
Sequential Performance Killers: You're awaiting API calls one by one when they could run in parallel, adding 300ms+ to every user interaction. Your app feels sluggish, users bounce, and you can't figure out why.
React Component Memory Leaks: Setting state in async callbacks after components unmount. Your app gradually consumes more memory, performance degrades, and users start complaining about browser crashes.
Testing Nightmares: Async tests that pass locally but fail in CI, or worse—tests that fail silently because they don't properly wait for promises to resolve.
These rules establish bulletproof patterns for every async scenario you'll encounter:
// Before: Fragile, error-prone async code
async function loadUserData(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(userId);
const comments = await fetchComments(userId);
return { user, posts, comments };
}
// After: Robust, parallel, error-handled async code
async function loadUserData(userId: string): Promise<UserData> {
if (!userId) throw new ValidationError('userId required');
const [user, posts, comments] = await Promise.allSettled([
fetchUser(userId),
fetchPosts(userId),
fetchComments(userId)
]);
return {
user: user.status === 'fulfilled' ? user.value : null,
posts: posts.status === 'fulfilled' ? posts.value : [],
comments: comments.status === 'fulfilled' ? comments.value : []
};
}
3x Faster Debugging: Explicit error handling and proper typing means stack traces point directly to the problem. No more hunting through callback chains or wondering where promises got swallowed.
50% Reduction in Race Conditions: AbortController patterns and proper component cleanup eliminate the most common source of React bugs. Your components work reliably, every time.
40% Performance Improvement: Strategic use of Promise.all and Promise.allSettled eliminates unnecessary sequential waits. Your API calls run in parallel, your users see data faster.
Zero Unhandled Promise Rejections: Global error handlers catch forgotten promises before they hit production. Your error logs become actionable instead of mysterious.
// Robust API service with proper error handling and cancellation
export class UserService {
private static async withErrorHandling<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
try {
return await operation();
} catch (error) {
throw new ServiceError(`${context} failed`, error);
}
}
static async getUser(id: string, signal?: AbortSignal): Promise<User> {
if (!id) throw new ValidationError('User ID required');
return this.withErrorHandling(
() => fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
'User fetch'
);
}
}
// Custom hook that properly handles cleanup
export function useAsyncData<T>(
asyncFn: () => Promise<T>,
deps: React.DependencyList
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchData() {
setLoading(true);
setError(null);
try {
const result = await asyncFn();
if (!abortController.signal.aborted) {
setData(result);
}
} catch (err) {
if (!abortController.signal.aborted) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!abortController.signal.aborted) {
setLoading(false);
}
}
}
fetchData();
return () => abortController.abort();
}, deps);
return { data, loading, error };
}
// Wrapper that eliminates try/catch boilerplate
export const asyncHandler = (fn: AsyncHandler) => (
req: Request,
res: Response,
next: NextFunction
) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Clean, error-safe route handlers
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await UserService.getUser(req.params.id);
res.json(user);
}));
.cursorrules file# Install essential async development tools
npm install --save-dev @typescript-eslint/eslint-plugin eslint-plugin-promise
# Add to your ESLint config
{
"rules": {
"@typescript-eslint/return-await": "error",
"promise/always-return": "error",
"promise/no-nesting": "warn",
"require-await": "error",
"@typescript-eslint/no-floating-promises": "error"
}
}
// In your main application file
if (typeof window !== 'undefined') {
window.onunhandledrejection = (event) => {
console.error('Unhandled promise rejection:', event.reason);
// Report to your error service
};
} else {
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Report to your error service
});
}
Use Cursor's AI to automatically transform your existing async code:
Week 1: Your async code becomes more readable and maintainable. Error handling is consistent across your entire codebase. TypeScript catches async bugs before they reach production.
Week 2: Performance improvements become noticeable. API calls run in parallel, reducing page load times. Your React components stop causing memory leaks.
Week 3: Testing becomes easier. Your async functions are predictable and deterministic. CI/CD pipelines run more reliably because async tests work consistently.
Month 1: You've eliminated the most common categories of production bugs. Your error logs are cleaner and more actionable. New team members can contribute to async code without introducing subtle bugs.
These rules don't just make your code better—they make you more confident shipping async JavaScript to production. Your users get faster, more reliable experiences, and you spend less time debugging mysterious async issues.
The async JavaScript patterns that once felt unpredictable and error-prone become your most reliable development tools. You'll write async code that works the first time, scales properly, and fails gracefully when things go wrong.
You are an expert in JavaScript (ES2023), TypeScript 5+, Node.js 20+, React 18+, Next.js 14+, Express 4+, Axios, Web Workers, and browser APIs such as Fetch and AbortController.
Key Principles
- Prefer Promises and async/await over callbacks; avoid callback hell entirely.
- Keep async functions small, single-purpose, and side-effect-free when possible.
- Always make the happy path obvious—handle errors first, then return the normal result.
- Fail fast on invalid input; do not let bad data propagate.
- Make concurrency explicit: name functions with verbs that reflect timing (e.g., fetchUser, streamLogs).
- Never block the event loop; move CPU-heavy work to Worker Threads/Web Workers.
- Treat cancellation as a first-class concept using AbortController.
- Write code that is test-friendly: deterministic, dependency-injected, and returning Promises.
JavaScript / TypeScript Rules
- Use `const` by default, `let` when reassignment is required; avoid `var`.
- Prefer top-level await in ESM modules only when initialization absolutely must finish before export.
- Always mark functions that return a Promise with the `async` keyword—even if they currently just `return Promise.resolve()`—to document intent.
- Never `await` inside a loop when operations are independent; aggregate them with `Promise.all` or `Promise.allSettled`.
- Use `Promise.race` only when you *really* need the fastest response; guard with timeout or cancellation.
- Wrap third-party callback-based APIs with `util.promisify` or a manual Promise wrapper once, then reuse.
- TypeScript-specific:
• Declare async return types explicitly: `async function load(): Promise<User[]> { … }`.
• Use `UnknownAsync<T>` style aliases if enforcing input/output unknowns.
• Enable `--strict` and `--noImplicitReturns` to catch missing awaits.
- Naming conventions: suffix pure async values with `Promise` (e.g., `userPromise`) when the variable holds the Promise itself.
Error Handling and Validation
- Top-level rule: *every* Promise chain ends with `.catch` or awaits inside a `try/catch`.
- In `catch` blocks, re-throw library-level errors as domain-specific ones (`new DataFetchError(err)`).
- Use early returns for parameter validation:
```ts
export async function createUser(dto: NewUserDTO): Promise<User> {
if (!dto.email) throw new ValidationError('email required');
… // safe to proceed
}
```
- Register a single global handler in Node (`process.on('unhandledRejection', …)`) and browser (`window.onunhandledrejection`) that logs and surfaces forgotten rejections.
- For parallel flows (`Promise.all`), wrap each promise with `.catch` if you need per-item error visibility.
- AbortController pattern:
```ts
const ac = new AbortController();
fetch(url, { signal: ac.signal }).catch(handleAbort);
ac.abort();
```
Framework-Specific Rules
React 18+ / Next.js 14+
- Data fetching inside components must use Suspense or React Query rather than raw useEffect for predictable waterfalls.
- Components returning Promises are allowed only in server components (Next.js). Client components must resolve data before render.
- Never set state inside an async function without first checking `isMounted` or using an `AbortController` pattern to avoid setting state on unmounted component.
- Handle errors with Error Boundaries; throw inside async functions and let the boundary render fallback UI.
Express 4+
- Export every route handler as an async function and pass errors to `next(err)` by wrapping:
```ts
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);
router.get('/user/:id', wrap(async (req) => {
const user = await db.user.get(req.params.id);
return res.json(user);
}));
```
- Register an error-handling middleware *after* all routes to format JSON errors consistently.
Additional Sections
Testing
- Use Jest or Vitest with `test.concurrent` for parallel promise tests.
- Always return the Promise or mark the test function `async`; otherwise the framework cannot detect failures.
- Use fake timers (`vi.useFakeTimers()` or `jest.useFakeTimers()`) to test timeout/cancellation logic deterministically.
Performance
- Parallelize I/O with `Promise.all` where ordering is irrelevant; document why if sequential awaits remain.
- Use `Promise.allSettled` for fire-and-collect scenarios where partial failure is acceptable.
- Offload CPU-bound work (image resize, crypto) to Worker Threads/Web Workers; expose an async wrapper that returns a Promise.
- Debounce or throttle rapid async requests (e.g., search suggestions) on the client using `requestIdleCallback` or libraries like lodash/throttle.
Security
- Never `eval` or `Function()` asynchronous payloads.
- Sanitize all user input before using it to build dynamic async queries (SQL, GraphQL, NoSQL, etc.).
- Do not leak stack traces in production—map internal errors to generic messages while retaining logs server-side.
- Enforce HTTPS and SameSite cookies when making credentialed fetch requests.
File/Folder Conventions
- `services/` – pure async logic (e.g., `user-service.ts`).
- `routes/` – Express async route handlers.
- `hooks/` – React hooks (`useAsyncRetry.ts`).
- `workers/` – isolated worker files (e.g., `thumbnail.worker.ts`).
- Each file exports a single default async function plus related helpers.
Common Pitfalls & How to Avoid
- Forgetting `await` → enable ESLint rule `require-await` + TypeScript `no-floating-promises`.
- Silent Promise rejection → always attach `.catch` or global handler.
- Sequential await loops → replace with `Promise.all` unless order matters.
- Memory leaks in React due to stale async callbacks → abort on unmount.
Ready-to-Use ESLint Snippets
```jsonc
{
"rules": {
"@typescript-eslint/return-await": "error",
"promise/always-return": "error",
"promise/no-nesting": "warn",
"require-await": "error"
}
}
```
By following these rules you will write readable, testable, and high-performance asynchronous JavaScript that scales both in the browser and on Node.js servers.