StackPractices
intermediate By Mathias Paulenko

Master Async Patterns with Promises, Futures, and Coroutines

How to write efficient concurrent code using async/await, promises, futures, and coroutines in JavaScript, Python, and Java for non-blocking I/O and parallel processing.

Overview

Synchronous code blocks the execution thread until an operation completes. When that operation is I/O — querying a database, fetching from an API, reading a file — the thread sits idle, wasting CPU cycles that could process other requests. Async programming solves this by suspending the current task when it encounters I/O, allowing the runtime to execute other tasks, and resuming the original task when the I/O completes. This enables a single thread to handle thousands of concurrent connections.

The challenge is not writing async and await keywords — it is understanding the underlying event loop, avoiding callback hell, handling errors across suspension points, and preventing resource contention when multiple tasks access shared state. Different runtimes implement async differently: JavaScript uses an event loop with promises, Python uses asyncio with coroutines, and Java uses CompletableFuture with thread pools. Here is how to patterns, anti-patterns, and practical implementations across all three.

When to use it

Use this recipe when:

  • Building APIs that handle hundreds of concurrent requests per process
  • Fetching data from multiple services that can be called in parallel
  • Processing I/O-bound workloads like web scraping, file uploads, or message queues
  • Implementing real-time capabilities como WebSockets, chat, o live dashboards
  • Replacing thread-per-request models with event-driven architectures for efficiency

Solution

Async/Await with Concurrent Requests (JavaScript / Node.js)

async function fetchUserDashboard(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    getProfile(userId),
    getOrders(userId),
    getRecommendations(userId),
  ]);
  return { profile, orders, recommendations };
}

async function fetchDashboardResilient(userId) {
  const [profile, orders, recommendations] = await Promise.allSettled([
    getProfile(userId),
    getOrders(userId),
    getRecommendations(userId),
  ]);

  return {
    profile: profile.status === 'fulfilled' ? profile.value : null,
    orders: orders.status === 'fulfilled' ? orders.value : [],
    recommendations: recommendations.status === 'fulfilled' ? recommendations.value : [],
  };
}

Python asyncio with Task Groups

import asyncio
import aiohttp

async def fetch_url(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url) as response:
        return await response.json()

async def fetch_all_urls(urls: list[str]) -> list[dict]:
    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch_url(session, url)) for url in urls]
        return [task.result() for task in tasks]

async def fetch_with_limit(urls: list[str], max_concurrent: int = 10):
    semaphore = asyncio.Semaphore(max_concurrent)

    async def bounded_fetch(session, url):
        async with semaphore:
            return await fetch_url(session, url)

    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(
            *[bounded_fetch(session, url) for url in urls]
        )

urls = ["https://api.example.com/users/1", "https://api.example.com/users/2"]
results = asyncio.run(fetch_all_urls(urls))

Java CompletableFuture Pipeline

import java.util.concurrent.CompletableFuture;

public class AsyncOrderService {
    public CompletableFuture<Order> processOrder(String orderId) {
        return validateOrder(orderId)
            .thenCompose(this::checkInventory)
            .thenCompose(this::processPayment)
            .thenCompose(this::createShipment)
            .exceptionally(ex -> {
                log.error("Order processing failed", ex);
                return Order.failed(orderId, ex.getMessage());
            });
    }

    private CompletableFuture<ValidatedOrder> validateOrder(String orderId) {
        return CompletableFuture.supplyAsync(() -> new ValidatedOrder(orderId));
    }

    public CompletableFuture<Dashboard> loadDashboard(String userId) {
        CompletableFuture<Profile> profileFuture = fetchProfile(userId);
        CompletableFuture<List<Order>> ordersFuture = fetchOrders(userId);
        return profileFuture.thenCombine(ordersFuture, Dashboard::new);
    }
}

Explanation

  • Event loop: the core mechanism in JavaScript and Python asyncio. It maintains a queue of tasks and executes them one at a time. When a task hits an await, it yields control, and the loop picks up the next task. When the awaited operation completes, the task is rescheduled. This single-threaded concurrency avoids the overhead of thread switching.
  • Structured concurrency: in Python 3. 11+, asyncio. TaskGroup ensures that if any child task fails, all other tasks in the group are cancelled. This prevents orphaned background tasks that leak memory or hold resources after a parent failure.
  • Promise composition: JavaScript promises chain via . then() and . catch(). Promise. all() waits for all promises, failing fast if any rejects. Promise. allSettled() waits for all, returning both successes and failures. Promise. race() returns the first to settle.
  • Backpressure with semaphores: unbounded concurrency exhausts memory, file descriptors, and upstream quotas. A semaphore limits the number of simultaneous operations. With a limit of 10, only 10 HTTP requests are in flight at any time; the 11th waits until a slot frees.

Variants

PatternLanguageConcurrency modelError handlingBest for
async/awaitJS/PythonEvent looptry/catchI/O-bound APIs
CompletableFutureJavaThread poolexceptionally()CPU + I/O mixed
GoroutinesGoM:N threadsChannelsHigh-throughput services
RxJS/RxPYJS/PythonObservablesonErrorEvent streams
ThreadsAllOS threadstry/catchCPU-bound tasks

What works

  • Always await promises: an unawaited promise is a fire-and-forget operation that silently swallows errors. If a promise rejects and nothing awaits it, Node. js emits an unhandledRejection warning. In async functions, always await or . catch() every promise.
  • Use Promise.all for independence, sequential for dependencies: if task B needs the result of task A, they must run sequentially. If they are independent, use Promise. all or asyncio. gather to run them concurrently. Running independent tasks sequentially wastes time.
  • Set timeouts on all external calls: an unresponsive API can hang an async operation indefinitely. Wrap every external call in a timeout with retry logic. This prevents resource leaks and ensures predictable latencies.
  • Prefer structured concurrency over fire-and-forget: spawning a background task that outlives its parent is a common source of memory leaks and race conditions. gather`, or explicit cancellation tokens to ensure lifetimes are managed.
  • Profile the event loop: in Node. js, use clinic. js or 0x to detect event loop lag. In Python, use asyncio. run with debug mode. If the event loop is blocked by CPU work, move it to a worker thread or process pool.

Common mistakes

  • Blocking the event loop: calling a synchronous file read (fs. readFileSync) or a heavy computation inside an async function blocks the entire event loop. All other requests stall. promises. readFile`) or offload CPU work to worker threads.
  • Callback hell without async/await: deeply nested . then() chains are hard to read and debug. Modern JavaScript should use async/await for all but the simplest cases. It produces flat, readable code that looks synchronous but executes asynchronously.
  • Race conditions on shared mutable state: two concurrent tasks incrementing a counter without synchronization produce incorrect results. In async environments, use atomic operations, locks, or message passing rather than shared mutable state.
  • Ignoring backpressure: accepting requests faster than they can be processed leads to memory exhaustion and OOM kills. A 503 response is better than a crashed server.

When Not to Use This Approach

  • CPU-bound tasks: async does not help when the CPU is the bottleneck.
  • Simple sequential scripts: if your script makes one HTTP call, waits, then exits, async adds complexity without benefit. A simple equests.get() is clearer than an async equivalent
  • Real-time systems with hard deadlines: async runtimes introduce non-deterministic scheduling.
  • Embedded systems with severe memory constraints: each pending async operation holds a callback and closure.
  • Legacy codebases without async support: retrofitting async into a synchronous codebase requires touching every I/O call in the call chain.
  • Debugging-sensitive environments: async stack traces are harder to read.
  • Single-request batch jobs: a nightly job that fetches one API endpoint and writes to a database gains nothing from async.

Performance Benchmarks

  • Node.js event loop: a single Node. js process handles 8,000-12,000 concurrent HTTP keep-alive connections on a 2-core VM with 4GB RAM.
  • Python asyncio vs threads: asyncio processes 15,000 HTTP requests/sec on a single core vs 3,000 with threading (aiohttp vs Flask+gunicorn threads).
  • Go goroutines: 100,000 goroutines consume ~400MB of stack memory (2KB initial stack each).
  • Rust tokio: tokio’s async runtime adds ~20ns per task spawn vs ~5us for an OS thread.
  • Java virtual threads: 1M virtual threads consume ~4GB heap vs 1M platform threads which would need ~2TB of stack.
  • Context switching: OS thread context switch takes 1-10us. Async task switch takes 100-500ns.
  • Memory per connection: Node. js uses ~2KB per keep-alive connection, Python asyncio uses ~4KB, Go goroutines use ~2KB initial, Java virtual threads use ~2KB.
  • Throughput scaling: async I/O throughput scales linearly with connections until CPU saturation.
  • Latency percentiles: async runtimes have tighter p99 latency (50-100ms) under load compared to thread pools (200-500ms) because there are no context switches or thread pool queue waits
  • GC pressure: each async task allocates a state machine object. In high-throughput scenarios, this creates 50-200MB/sec of garbage.

Testing Strategy

  • Unit test individual async functions in isolation: mock I/O dependencies and assert return values. mark.
  • Integration test with real I/O: spin up a local HTTP server and database. Verify end-to-end behavior under async load.
  • Stress test with high concurrency: launch 1,000+ concurrent tasks and verify no deadlocks, no resource leaks, and correct results.
  • Test timeout behavior: verify that slow operations trigger timeouts correctly. wait_for or Promise.
  • Test cancellation propagation: cancel a parent task and verify all child tasks are cancelled.
  • Test error propagation: assert that exceptions in child tasks bubble up to the parent. Verify that syncio.
  • Race condition detection: run tests with ThreadSanitizer (for threaded code) or use syncio debug mode (PYTHONASYNCIODEBUG=1) to detect unclosed resources and slow callbacks
  • Load test with realistic payloads: test with production-sized payloads, not toy data.
  • Test backpressure handling: send requests faster than the server can process and verify it responds with 503 or queues them, rather than running out of memory
  • Chaos testing: randomly kill tasks, inject network delays, and simulate disk failures.

Cost Estimation

  • Server sizing: async workloads need fewer servers. A typical Node. js async server handles 10K connections on a 2-core / 4GB instance (/month).
  • Connection pool licensing: async connection pools (e. g. , asyncpg, aiohttp) are open source.
  • Development cost: async code takes 20-30% longer to write and debug than synchronous equivalents.
  • Monitoring overhead: async runtimes need specialized monitoring (event loop lag, task queue depth, promise rejection tracking).
  • Infrastructure savings: moving from thread-per-request to async can reduce server count by 3-5x.
  • Memory cost: async tasks use 10-100x less memory than threads.
  • Operational cost: async systems have fewer moving parts (no thread pool tuning, no lock contention debugging).

Monitoring and Observability

  • Event loop lag: measure the delay between scheduled and executed callbacks. Lag >50ms indicates the event loop is blocked. Tools: clinic.
  • Task queue depth: track the number of pending tasks. A growing queue means tasks are produced faster than consumed.
  • Active connections: monitor concurrent connection count.
  • Promise rejection rate: track unhandled promise rejections (Node. js) or unhandled task exceptions (Python).
  • GC pause time: async runtimes generate many small objects.
  • Memory usage: track RSS and heap growth.
  • Request latency percentiles: track p50, p95, p99. Async systems should have tight percentiles.

Deployment Checklist

  • Set file descriptor limits: ulimit -n 65536 or configure systemd LimitNOFILE=65536
  • Configure connection pool sizes based on expected concurrency (pool size = 2 * CPU cores for async, not 50+)
  • Set timeouts on all I/O operations: HTTP clients, database queries, cache reads. Default to 5-30 seconds
  • Enable structured logging with request IDs for tracing async call chains
  • Configure health checks that verify the event loop is responsive, not just that the process is alive
  • Set memory limits and configure OOM killer behavior. Async tasks are lightweight but can accumulate
  • Enable graceful shutdown: drain pending tasks for 5-10 seconds before killing the process

Security Considerations

  • Resource exhaustion via task flooding: an attacker can spawn millions of async tasks by sending rapid requests.
  • Async callback injection: if user input controls which callback is executed, attackers can invoke arbitrary functions.
  • Promise rejection DoS: unhandled promise rejections in Node. js <15 crash the process. Always attach .
  • Event loop blocking: a single synchronous operation blocks the entire event loop. Audit all code paths for blocking calls ( s. readFileSync, ime. sleep, crypto. pbkdf2Sync).
  • Shared mutable state in async code: although async runs on a single thread, wait points allow interleaving. Shared state modified across wait boundaries can cause race conditions.
  • Timeout bypass: if a timeout is set on a task but the underlying I/O operation does not support cancellation, the task appears to time out but the I/O continues consuming resources.
  • Memory leaks via closures: each async task captures its scope in a closure. Long-lived tasks holding references to large objects prevent GC.
  • Supply chain risks in async libraries: popular async libraries (aiohttp, asyncio, tokio) have had CVEs. Pin versions and monitor security advisories.
  • Denial of service via slow clients: a slow HTTP client holds an async connection open.
  • Unsafe deserialization in async pipelines: async JSON parsing (wait response. json()) can be exploited with large payloads.
  • Coroutine spoofing: in Python, any object with await can be awaited. Malicious objects could execute code when awaited.
  • File descriptor exhaustion: each async connection uses a file descriptor. Without limits, a connection flood exhausts FDs and crashes the process.
  • Information leakage in error messages: async stack traces are deep and may expose internal paths, query strings, or credentials.
  • Insecure defaults in async HTTP clients: many async HTTP clients do not verify TLS certificates by default.
  • ReDoS in async input validation: regex validation running on the event loop can block for seconds on crafted input. Move regex to a worker thread or use e2 which has linear-time guarantees
  • Task cancellation race conditions: cancelling a task that performs a non-idempotent operation (e. g. , charge a credit card) can lead to double charges if the operation completes before cancellation propagates.
  • Async context manager leaks: failing to use sync with for resources (database connections, HTTP sessions) leaks connections.
  • Backpressure bypass: if a fast producer feeds a slow consumer without backpressure, memory grows unbounded.

Troubleshooting

  • Race conditions appear under load: protect shared state with locks, atomics, or message passing. Reproduce with targeted stress tests.
  • Deadlock between workers: establish a consistent lock acquisition order and keep critical sections short.
  • Thread pool saturation: monitor queue length and rejection policy. Increase pool size only if CPU and memory allow.
  • Actor mailbox grows unbounded: apply backpressure, bounded queues, and load shedding.
  • Async task never completes: check for unhandled promise rejections, forgotten awaits, and infinite loops in cooperative scheduling.

Further Reading

  • Official documentation: check the current reference for the framework or tool used.
  • Related guides: explore the concurrency and event-loop guides for deeper coverage.
  • Complementary patterns: review design patterns applicable to your technology stack.
  • Public postmortems: study real incidents from teams that faced similar production issues.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply master async patterns with promises, futures, and coroutines when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

Is async always faster than synchronous?

Only for I/O-bound workloads. For CPU-bound tasks (image processing, machine learning), async provides no benefit because the CPU is already saturated. Use threads, processes, or dedicated workers for CPU parallelism.

How many concurrent requests can a single Node.js process handle?

Thousands, limited by memory and file descriptors. The event loop handles one operation at a time, but most operations are I/O waits. A typical Node.js server handles 5,000-10,000 concurrent connections.

What is the difference between concurrency and parallelism?

Concurrency is interleaving tasks on a single core (async/await). Parallelism is running tasks simultaneously on multiple cores (threads/processes). Async provides concurrency; multiprocessing provides parallelism. Use both for maximum throughput.

Should I use threads or async in Python?

Use asyncio for I/O-bound workloads with many connections. Use threading for I/O with blocking libraries that do not support async. Use multiprocessing for CPU-bound work that must bypass the GIL. asyncio is usually the best choice for web servers and API clients.