Branch8

Async JavaScript Programming Best Practices for APAC Teams

Elton Chan
October 28, 2026
15 mins read
Async JavaScript Programming Best Practices for APAC Teams - Hero Image

Key Takeaways

  • Enable type-aware lint rules: `no-floating-promises` catches most async bugs.
  • Default to parallel, but cap fan-out with `p-limit` against rate limits.
  • Use `AbortController` and `AbortSignal.timeout()` on every network call.
  • Offshore bounded async tasks; keep concurrency architecture decisions in-house.
  • `AsyncLocalStorage` plus tracing makes cross-time-zone debugging viable.

Quick Answer: Async JavaScript best practices: default to parallel with Promise.all/allSettled but cap fan-out, always set timeouts via AbortSignal.timeout(), catch errors at decision boundaries with cause, and enforce no-floating-promises in CI so standards hold across distributed teams.


Stack Overflow's 2024 Developer Survey put JavaScript at the top of the most-used technologies list for the twelfth year running, with roughly 62% of the 65,000+ respondents using it. Yet in my experience reviewing code from engineering teams across Hong Kong, Singapore, Ho Chi Minh City and Manila, the single largest source of production incidents isn't algorithmic complexity or infrastructure — it's unhandled asynchrony. A floating promise. A Promise.all that fans out 400 concurrent calls to a payment gateway. A React effect that races itself when a user types fast on a 200ms-latency connection between Sydney and a Frankfurt API.

Related reading: AI Talent Retention in the Tech Industry: A 2026 APAC Playbook

Related reading: 10 GbE USB Infrastructure Optimization for APAC Engineering Teams

Related reading: Geothermal Energy Enterprise Infrastructure 2026: The APAC Angle

That's why async JavaScript programming best practices are less of an "advanced topic" and more of a team-level operating standard. And when your team is distributed — a product lead in Hong Kong, two React engineers in Taipei, a Node.js backend pod in Vietnam, QA in the Philippines — the cost of an inconsistent async model compounds. Nobody is in the same room to explain why that await inside a loop is silently serialising 60 requests.

This guide is written for the team-design problem, not just the syntax problem. Each step has a code standard you can lift into a repo, plus the hiring and review implications of enforcing it across time zones.

Prerequisites: What Your Team Needs Before Step 1

Don't start rewriting code. Start by confirming the baseline, because half of the async patterns below are only available on modern runtimes.

Runtime and language baseline

According to the Node.js release schedule published on nodejs.org, Node 18 reached end-of-life in April 2025, which means any team still shipping on 16 or 18 is running unsupported infrastructure. Target Node 20 LTS or 22 LTS. That gives you stable global fetch, AbortSignal.timeout(), Array.fromAsync, and a --test runner you don't need to install.

On the browser side, MDN's compatibility data shows async/await has been supported across all major evergreen browsers since 2017, and top-level await in ES modules since 2021. If you're still transpiling async functions down to generators for legacy targets, check your Babel target list — that transform inflates bundle size for browsers nobody uses anymore.

1node -v # expect v20.x or v22.x
2npx tsc --version # 5.x for satisfies, const type params
3npx browserslist # audit what you're actually transpiling for

TypeScript, not because it's fashionable

The 2024 State of JS survey reported that over 60% of respondents write more TypeScript than plain JavaScript. For async work specifically, the value isn't type safety in the abstract — it's that typescript-eslint's type-aware rules (no-floating-promises, no-misused-promises, await-thenable) simply cannot run without type information. Those three rules catch the majority of async bugs I see in code review. No types, no rules, no safety net.

A shared mental model of the event loop

Before a single PR, every engineer on the team — including the ones you onboarded last week in Manila — should be able to predict the output of this snippet:

1console.log('1');
2setTimeout(() => console.log('2'), 0);
3Promise.resolve().then(() => console.log('3'));
4queueMicrotask(() => console.log('4'));
5console.log('5');
6// 1, 5, 3, 4, 2

Microtasks (promise callbacks, queueMicrotask) drain completely before the next macrotask (setTimeout, I/O). If someone can't explain that ordering, they will eventually write a setTimeout(fn, 0) where they needed a microtask, and the bug will surface only under load. I use this exact snippet as a 90-second screening question. It's a better signal than most take-home tests.

Step 1: Standardise How Promises Are Created and Consumed

Kill the callback-to-promise conversions by hand

Callback hell is the problem everyone writes about, and it's mostly solved. The residual problem is half-converted code: a codebase where some modules use callbacks, some use promises, and some wrap callbacks in hand-rolled new Promise constructors that forget to reject.

Node's util.promisify and the node:fs/promises-style APIs remove the need for manual wrapping:

1import { promisify } from 'node:util';
2import { readFile } from 'node:fs/promises';
3import { legacyThing } from './vendor-sdk.js';
4
5const doThing = promisify(legacyThing); // error-first callback → promise
6
7const config = await readFile('./config.json', 'utf8');

The rule for the repo: new Promise() is only permitted when adapting a genuinely non-promise API (an event emitter, a WebSocket handshake, a legacy SDK). Everywhere else it's a code smell called the explicit promise construction antipattern:

1// Don't — wraps a promise in a promise, loses rejections
2function getUser(id) {
3 return new Promise((resolve) => {
4 fetchUser(id).then(resolve);
5 });
6}
7
8// Do
9function getUser(id) {
10 return fetchUser(id);
11}

Pick one style per layer and write it down

async/await for business logic and sequential flows. Raw .then() chains only where you need to pass a promise around without awaiting it — for example, kicking off a cache warm and storing the promise for later. Mixing both in the same function is where reviewers lose the thread.

When I ran electronics category engineering at Lazada, the codebase spanned six markets and the async style varied by whoever wrote the module first. The fix wasn't a rewrite; it was a one-page convention doc plus lint rules. Convention beats refactoring when your team spans four time zones and code review happens asynchronously anyway.

What is an asynchronous function in JavaScript, precisely?

An async function always returns a promise, regardless of what you write in the body. return 5 becomes Promise.resolve(5); a thrown error becomes a rejected promise. This matters for a subtle reason distributed teams hit constantly: adding async to a function is a breaking change to its contract. Every call site that used the return value synchronously is now broken. In a monorepo with engineers in three countries, that's a merge conflict you find in staging, not in review.

Ready to Transform Your Ecommerce Operations?

Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.

Step 2: Make Error Handling Explicit at Every Boundary

try/catch/finally with a real boundary

Google's Chrome team notes on web.dev that unhandled promise rejections in browsers are reported through the unhandledrejection event; in Node 15+, they crash the process by default. That default is correct — a silent rejection is worse than a crash — but it means you need deliberate boundaries.

The pattern I enforce: catch at the boundary where you can make a decision, not at every level.

1async function syncInventory(sku) {
2 const res = await fetch(`${API}/inventory/${sku}`, {
3 signal: AbortSignal.timeout(5000),
4 });
5 if (!res.ok) {
6 throw new Error(`Inventory sync failed: ${res.status}`, {
7 cause: { sku, status: res.status },
8 });
9 }
10 return res.json();
11}
12
13// Boundary: here we decide to degrade rather than fail
14async function renderProductPage(sku) {
15 try {
16 return { stock: await syncInventory(sku) };
17 } catch (err) {
18 logger.warn({ err, sku }, 'inventory unavailable, degrading');
19 return { stock: null };
20 } finally {
21 metrics.increment('product_page.render');
22 }
23}

The cause option on Error has been in the ECMAScript spec since ES2022 and is documented on MDN. Use it. It preserves the original failure without string-concatenating stack traces, which matters enormously when the engineer debugging the log at 09:00 in Taipei is not the engineer who wrote the code at 16:00 in Kuala Lumpur.

Never let a rejection float

A floating promise is a promise whose rejection nobody handles. It's the async equivalent of an unread Slack message from your CFO.

1// Floating — rejection is unhandled
2sendAnalytics(event);
3
4// Explicit fire-and-forget
5void sendAnalytics(event).catch((err) =>
6 logger.error({ err }, 'analytics dropped'),
7);

The typescript-eslint documentation lists no-floating-promises as one of its most impactful type-aware rules precisely because this class of bug is invisible in review. Turn it on at error, not warn.

Global safety nets, not global strategies

1process.on('unhandledRejection', (reason) => {
2 logger.fatal({ reason }, 'unhandled rejection — exiting');
3 process.exit(1);
4});

This is a safety net that tells you your lint rules failed, not a handling strategy. If this handler fires in production more than rarely, you have a code standard problem.

Step 3: Control Concurrency Deliberately

This is where most teams lose real money, and it's the section that separates async JavaScript programming best practices from async JavaScript trivia.

Parallel by default, sequential by intent

The classic mistake:

1// Serial: total latency = sum of all requests
2for (const id of orderIds) {
3 results.push(await fetchOrder(id));
4}

With 50 orders and a 180ms round trip from a Singapore server to a US-East API — roughly the figure you'd expect given AWS's published inter-region latency characteristics — that loop takes about nine seconds. Parallelised, it takes about 200ms. Same code, 45x difference.

1const results = await Promise.all(orderIds.map(fetchOrder));

But Promise.all is the wrong default too, because it fails fast and fans out without limit.

Choose the right combinator

  • Promise.all — all must succeed, first rejection wins. Use for data your page cannot render without.
  • Promise.allSettled — never rejects, returns status per entry. Use for partial-render dashboards and batch jobs where one bad record shouldn't kill the run.
  • Promise.any — first fulfilment wins. Useful for racing two CDN edges or two read replicas.
  • Promise.race — first settlement wins, including rejections. Mostly superseded by AbortSignal.timeout() for timeout use cases.
1const settled = await Promise.allSettled(skus.map(syncInventory));
2const failures = settled.filter((r) => r.status === 'rejected');
3if (failures.length) {
4 logger.warn({ count: failures.length }, 'partial inventory sync');
5}

Cap the fan-out

Unbounded Promise.all over a large array is a self-inflicted denial of service. Most payment and logistics APIs across APAC — Stripe, Adyen, and regional carriers alike — publish per-second rate limits in the tens to low hundreds. Fan out 2,000 requests and you'll get 429s, then your retry logic will make it worse.

Related reading: Best E-Commerce Platforms 2026 Comparison: An APAC Lens

1import pLimit from 'p-limit';
2
3const limit = pLimit(8); // tune to the provider's documented limit
4const results = await Promise.allSettled(
5 skus.map((sku) => limit(() => syncInventory(sku))),
6);

For streaming workloads, async iterators plus for await give you natural backpressure:

1async function* paginate(url) {
2 let next = url;
3 while (next) {
4 const res = await fetch(next, { signal: AbortSignal.timeout(10_000) });
5 const page = await res.json();
6 yield* page.items;
7 next = page.next;
8 }
9}
10
11for await (const item of paginate(`${API}/orders`)) {
12 await processOne(item); // one at a time, memory stays flat
13}

I've seen a Greater China jewellery retailer's catalogue sync go from a nightly job that occasionally took down an ERP integration to a predictable, rate-limited pipeline purely by introducing a concurrency cap and an async generator. No new infrastructure. Two files changed.

Related reading: AI CDP Integration & Customer Engagement Automation: Build Guide

Ready to Transform Your Ecommerce Operations?

Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.

Step 4: Add Cancellation and Timeouts Everywhere

AbortController is not optional

Google's web.dev guidance sets the "good" threshold for Interaction to Next Paint at 200 milliseconds. You cannot hit that if a stale request from three keystrokes ago is still allowed to overwrite state.

1const controller = new AbortController();
2
3const res = await fetch(url, {
4 signal: AbortSignal.any([
5 controller.signal,
6 AbortSignal.timeout(8000),
7 ]),
8});
9
10// elsewhere
11controller.abort(new DOMException('User navigated', 'AbortError'));

AbortSignal.timeout() and AbortSignal.any() are both documented on MDN and available in Node 20+ and current browsers. They replaced a lot of hand-rolled Promise.race timeout helpers.

React: the race condition you will definitely ship

1useEffect(() => {
2 const controller = new AbortController();
3 let cancelled = false;
4
5 (async () => {
6 try {
7 const res = await fetch(`/api/search?q=${query}`, {
8 signal: controller.signal,
9 });
10 const data = await res.json();
11 if (!cancelled) setResults(data);
12 } catch (err) {
13 if (err.name !== 'AbortError') setError(err);
14 }
15 })();
16
17 return () => {
18 cancelled = true;
19 controller.abort();
20 };
21}, [query]);

The React documentation on react.dev explicitly recommends cleanup functions with an ignore flag for exactly this reason — and also recommends you stop writing this by hand. TanStack Query, SWR, or a framework loader (Next.js App Router, Remix) handles cancellation, deduplication and caching for you. My rule for teams: if you have more than five hand-written fetching effects in a codebase, adopt a data-fetching library. Every one of those effects is a race condition waiting for a slow network — and users on mobile networks in Indonesia or the Philippines find them far faster than your team on office fibre in Central.

Retries need jitter, not just loops

1async function withRetry(fn, { attempts = 4, base = 200 } = {}) {
2 for (let i = 0; i < attempts; i++) {
3 try {
4 return await fn();
5 } catch (err) {
6 if (i === attempts - 1 || !isRetryable(err)) throw err;
7 const delay = base * 2 ** i * (0.5 + Math.random());
8 await new Promise((r) => setTimeout(r, delay));
9 }
10 }
11}

The AWS Architecture Blog's well-known analysis of exponential backoff and jitter shows that backoff without randomisation causes retry storms, where every client retries in lockstep. Only retry idempotent operations, and never retry a 4xx that isn't 429 or 408.

Step 5: Enforce the Standard With Tooling, Not Code Review

Code review across time zones has a latency cost. A PR raised at 18:00 in Taipei gets reviewed at 10:00 the next morning in Hong Kong — an 16-hour round trip for a comment that says "you forgot to await this." Automate it.

1// eslint.config.js
2import tseslint from 'typescript-eslint';
3
4export default tseslint.config(
5 ...tseslint.configs.recommendedTypeChecked,
6 {
7 languageOptions: {
8 parserOptions: { projectService: true },
9 },
10 rules: {
11 '@typescript-eslint/no-floating-promises': 'error',
12 '@typescript-eslint/no-misused-promises': 'error',
13 '@typescript-eslint/await-thenable': 'error',
14 '@typescript-eslint/return-await': ['error', 'in-try-catch'],
15 'require-atomic-updates': 'error',
16 'no-return-await': 'off',
17 },
18 },
19);

no-misused-promises is the one that catches the genuinely dangerous case: passing an async function where a void-returning callback is expected, such as arr.filter(async (x) => ...), which always returns truthy promises and silently keeps every element.

Run these in CI with --max-warnings 0. A lint rule is a code reviewer that works in every time zone, never gets tired, and never softens feedback because of hierarchy — which, candidly, is a real factor in some APAC engineering cultures where junior engineers are reluctant to push back on a senior's PR.

Ready to Transform Your Ecommerce Operations?

Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.

Step 6: Make Async Work Observable Across Time Zones

AsyncLocalStorage for request context

When an incident happens at 03:00 Hong Kong time and the on-call engineer is in Manila, the only thing that matters is whether logs can be correlated. Node's AsyncLocalStorage (documented under node:async_context on nodejs.org) propagates context across await boundaries without threading a parameter through every function.

1import { AsyncLocalStorage } from 'node:async_hooks';
2import { randomUUID } from 'node:crypto';
3
4export const ctx = new AsyncLocalStorage();
5
6app.use((req, res, next) => {
7 ctx.run({ requestId: req.header('x-request-id') ?? randomUUID() }, next);
8});
9
10// anywhere, 12 awaits deep
11logger.info({ requestId: ctx.getStore()?.requestId }, 'charging card');

Distributed tracing over guesswork

OpenTelemetry's JavaScript SDK auto-instruments http, fetch, and most database clients, and its documentation lists async context propagation as a first-class concern. For a Node service fronting a React app, tracing turns "the checkout is slow sometimes" into "the tax service p99 is 2.4 seconds from our Singapore region." That distinction is the difference between a one-day fix and a two-week investigation.

Load-test from where your users are

A detail teams outside the region miss: Cloudflare's Radar data consistently shows mobile traffic share above 70% in several Southeast Asian markets, far higher than in the US or Western Europe. Async code that behaves fine on a 20ms office connection behaves very differently at 400ms with 2% packet loss. Test with throttling on, or your timeout and retry constants are guesses.

Step 7: Decide Which Async Work to Offshore — and Which to Keep Close

This is the part the tutorials skip. Async correctness is not evenly distributed across task types, so it shouldn't be evenly distributed across your team map either.

Work that offshores well

Bounded, well-specified async work with clear acceptance criteria: API client modules, pagination and sync jobs, retry and rate-limit middleware, test suites for async flows, migration of callback code to promises. These have deterministic definitions of done and are verifiable by CI. At Second Talent we've pre-vetted over 100,000 developers, and the pattern is consistent — mid-level Node.js engineers in Vietnam and the Philippines deliver this category reliably when the interface contract is written down first.

The market differences are real and worth naming. Vietnam's pool skews strongly toward Node.js and Java backend work with strong algorithmic fundamentals, reflecting a pipeline out of technical universities in Hanoi and HCMC. The Philippines skews toward front-end, React, and customer-facing product work, with the strongest English-language communication in the region — which matters more than people admit for async code review, where all feedback is written. Taiwan has deeper hardware-adjacent and systems engineering depth. Indonesia's pool is the fastest-growing but more variable in seniority distribution.

Work that should stay with your core team

Concurrency architecture decisions. Anything touching money, idempotency keys, or distributed transactions. The definition of your error taxonomy and retry policy. These are decisions where being wrong is expensive and the feedback loop is slow — exactly the conditions under which a 7-hour time-zone gap hurts most.

A framing I borrow from consulting days: separate decision rights from execution capacity. Offshore execution capacity generously. Keep decision rights where the context lives. Teams that get burned by distributed engineering almost always outsourced a decision, not a task.

The overlap window is the real constraint

Hong Kong, Singapore, Taipei, Manila and Ho Chi Minh City sit within a two-hour band — you get a near-full working day of overlap. Add Sydney and you're still fine. Add London and the overlap drops to roughly two hours; add San Francisco and it's near zero. For a US or UK company using Asia as an operations hub, the practical implication is that async code standards must be documented and linted, because you cannot rely on a synchronous conversation to resolve them. The lint config is the contract.

Ready to Transform Your Ecommerce Operations?

Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.

Common Mistakes and How to Troubleshoot Them

await inside a loop where parallelism was fine

Symptom: an endpoint whose latency scales linearly with a collection size. Diagnosis: search for for (... of ...) containing await. Fix: Promise.all with a p-limit cap. Keep the sequential version only if each iteration depends on the previous, or if you're deliberately applying backpressure.

Forgetting await on a function that returns a promise

Symptom: tests pass locally, data missing in production; or a finally block runs before the work it was supposed to follow. Diagnosis: await-thenable and no-floating-promises catch this statically. Without types, you'll find it in logs.

async inside array callbacks

1// Broken: every element survives, because Promise is truthy
2const active = users.filter(async (u) => await isActive(u));
3
4// Correct
5const flags = await Promise.all(users.map(isActive));
6const active = users.filter((_, i) => flags[i]);

Array.prototype.forEach has the same problem: it ignores returned promises entirely, so the loop "completes" before any work finishes.

Mixing await with synchronous state mutation

ESLint's require-atomic-updates rule exists because x = x + await f() can read a stale x. In Node this manifests as intermittent, load-dependent bugs that never reproduce locally.

Blocking the event loop and blaming async

Async I/O does nothing for CPU-bound work. A 400ms JSON parse or a synchronous crypto operation blocks every other request on that Node process. The Node.js documentation's guide on not blocking the event loop is explicit about this: move CPU work to worker_threads, a child process, or a queue.

1import { Worker } from 'node:worker_threads';
2const worker = new Worker('./heavy-report.js', { workerData: { month } });
3const result = await new Promise((resolve, reject) => {
4 worker.once('message', resolve);
5 worker.once('error', reject);
6});

Swallowing errors in a catch-all

try { ... } catch (e) {} is how a two-hour incident becomes a two-day one. If you must suppress, log with context and a metric so it's visible on a dashboard.

Debugging techniques that actually work

Node's --async-stack-traces is on by default in modern versions and preserves the async call chain. Chrome DevTools shows async stack frames in the same way. For hangs, node --inspect plus a heap or CPU profile will tell you whether you're blocked on I/O or burning CPU. For promise-specific hangs, process.getActiveResourcesInfo() in Node 17+ lists what's keeping the loop alive.

Answering the Questions Teams Actually Ask

Can you use async without await?

Yes. An async function without await still returns a promise — useful when you want a uniform promise-returning interface or want errors converted to rejections. The reverse is now also true at module scope: top-level await works in ES modules. But async with no await in the body is usually a sign that someone added the keyword defensively; the require-await ESLint rule will flag it.

Is async better than multithreading?

They solve different problems. Async concurrency excels at I/O-bound work — thousands of concurrent network calls on one thread, with no lock contention and no per-thread memory overhead. Multithreading is what you need for CPU-bound parallelism. Node gives you both: the event loop for I/O and worker_threads for CPU. Choosing async for CPU work, or threads for I/O, is the mistake — not choosing one paradigm over the other. This is also why comparisons to asynchronous programming in Java are less useful than they look: Java's virtual threads (JEP 444, shipped in JDK 21 per Oracle's release notes) achieve similar throughput with a blocking-style programming model.

Why use async in JavaScript at all?

Because JavaScript runs your application logic on a single thread. Any synchronous wait — a network call, a file read, a database query — freezes everything else, including UI rendering. Async lets that work proceed in the background while the thread stays available. On the server, it's how a single Node process handles thousands of concurrent connections on modest hardware.

Ready to Transform Your Ecommerce Operations?

Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.

The Honest Trade-offs

Everything above has a cost, and you should know what you're buying.

Type-aware linting slows CI. On a large monorepo, recommendedTypeChecked can add minutes to every run. You'll need caching or scoped linting, and that's real engineering time.

Concurrency caps make you slower on purpose. pLimit(8) is worse than unbounded fan-out right up until the moment it saves your integration from a rate-limit cascade. If you have no external dependencies with rate limits, the added complexity may not pay for itself.

Abstracting fetching into TanStack Query or SWR means adopting someone else's cache semantics. That's usually the right call, but it's a dependency and a learning curve for every new hire.

And the distributed-team advice is not for everyone. If you're a five-person team in one office shipping a product still searching for fit, most of this is premature. Write clear async code, turn on no-floating-promises, and move on. The documentation, tracing and lint-as-contract discipline earns its keep when you cross roughly 15 engineers, or the moment you add a second time zone — whichever comes first. Equally, if your bottleneck is product clarity rather than engineering throughput, adding an offshore pod will amplify the ambiguity, not absorb it. Async JavaScript programming best practices are a force multiplier on a clear specification and a tax on a vague one.

If you're scaling a React or Node.js team across Asia-Pacific and want a candid read on which async workstreams to distribute and which to keep in-house, Branch8's engineering leads are happy to walk through your architecture and team map.

Sources

FAQ

Use `async`/`await` for sequential business logic, promise combinators (`Promise.all`, `allSettled`, `any`) for parallel work, and `AbortController` for cancellation and timeouts. Wrap decision boundaries in try/catch/finally, attach `cause` to rethrown errors for traceability, and enforce the standard with type-aware ESLint rules rather than relying on code review alone.

About the Author

Elton Chan

Co-Founder, Second Talent & Branch8

Elton Chan is Co-Founder of Second Talent, a global tech hiring platform connecting companies with top-tier tech talent across Asia, ranked #1 in Global Hiring on G2 with a network of over 100,000 pre-vetted developers. He is also Co-Founder of Branch8, a Y Combinator-backed (S15) e-commerce technology firm headquartered in Hong Kong. With 14 years of experience spanning management consulting at Accenture (Dublin), cross-border e-commerce at Lazada Group (Singapore) under Rocket Internet, and enterprise platform delivery at Branch8, Elton brings a rare blend of strategy, technology, and operations expertise. He served as Founding Chairman of the Hong Kong E-Commerce Business Association (HKEBA), driving digital commerce education and cross-border collaboration across Asia. His work bridges technology, talent, and business strategy to help companies scale in an increasingly remote and digital world.