asynchronous programming
synchronous code
async vs sync
coding practices
software development

Dealing with the boundary between async and synchronous code?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Most production systems mix asynchronous and synchronous code, especially during incremental modernization. The boundary between those worlds is where deadlocks, event-loop stalls, and timeout bugs often appear. Treating the boundary as an explicit interface, not an ad hoc shortcut, is the key to stable behavior.

Model the Boundary as a Dedicated Adapter Layer

A useful pattern is to isolate async-to-sync and sync-to-async transitions in one module per dependency domain. That adapter should own:

  • Timeout policy.
  • Cancellation propagation.
  • Error mapping.
  • Context propagation for logging and tracing.

This design avoids scattered conversion logic and makes review of boundary behavior straightforward.

Async Code Calling Blocking Sync Functions

Blocking calls must not run directly on the event loop. Use executor-based delegation so loop responsiveness is preserved.

python
1import asyncio
2from concurrent.futures import ThreadPoolExecutor
3import time
4
5
6def blocking_lookup(item_id: int) -> str:
7    time.sleep(0.2)
8    return f"item-{item_id}"
9
10
11async def lookup_async(item_id: int, pool: ThreadPoolExecutor) -> str:
12    loop = asyncio.get_running_loop()
13    return await loop.run_in_executor(pool, blocking_lookup, item_id)
14
15
16async def main():
17    with ThreadPoolExecutor(max_workers=4) as pool:
18        results = await asyncio.gather(*(lookup_async(i, pool) for i in range(5)))
19        print(results)
20
21
22asyncio.run(main())

This keeps async handlers non-blocking while allowing gradual reuse of legacy sync code.

Sync Entry Points Calling Async Internals

For command-line utilities and scripts, put loop creation at top-level boundaries only.

python
1import asyncio
2
3
4async def fetch_value() -> int:
5    await asyncio.sleep(0.1)
6    return 42
7
8
9def fetch_value_sync() -> int:
10    return asyncio.run(fetch_value())
11
12
13print(fetch_value_sync())

Do not call loop runners from code that may already be under an active loop, such as some web frameworks or notebook environments.

Timeout and Cancellation Contracts

Every boundary crossing needs explicit timeout behavior. Without it, blocking dependencies can consume worker capacity indefinitely.

python
1import asyncio
2
3
4async def unstable_call():
5    await asyncio.sleep(5)
6    return "ok"
7
8
9async def guarded_call():
10    try:
11        return await asyncio.wait_for(unstable_call(), timeout=1.0)
12    except asyncio.TimeoutError:
13        return "timeout"
14
15
16print(asyncio.run(guarded_call()))

Timeout defaults should be deliberate and documented per dependency type.

.NET Equivalent Boundary Guidance

In .NET, avoid wrapping naturally async I O in Task.Run. Prefer native async APIs and reserve thread delegation for CPU-bound work.

csharp
1using System.Net.Http;
2using System.Threading.Tasks;
3
4public static class ApiClient
5{
6    private static readonly HttpClient Client = new();
7
8    public static async Task<string> FetchAsync(string url)
9    {
10        return await Client.GetStringAsync(url);
11    }
12}

If sync callers exist, keep conversion adapters narrowly scoped and avoid widespread .Result usage.

Preserve Observability Across the Boundary

Crossing from async tasks to worker threads can lose correlation context if not handled carefully. At minimum, record:

  • Start and end of boundary calls.
  • Timeout counts.
  • Cancellation counts.
  • Exception type and dependency tags.

Boundary-level metrics make performance regressions and blocked paths visible before full outages.

Migration Strategy for Legacy Systems

A practical phased migration:

  1. Identify blocking hotspots from traces.
  2. Add explicit adapters at those boundaries.
  3. Apply timeout and retry policy in adapter layer.
  4. Replace legacy internals with native async implementations incrementally.
  5. Retire temporary adapters when direct async paths are stable.

This approach improves reliability without requiring full rewrite risk.

Common Pitfalls

  • Calling blocking sync functions directly from async request handlers.
  • Spreading conversion logic across many modules with inconsistent timeout policy.
  • Starting nested event loops in contexts that already have one.
  • Ignoring cancellation propagation and leaking work.
  • Losing correlation metadata when moving work to executors.

Summary

  • Mixed async and sync code is manageable with explicit adapter boundaries.
  • Run blocking sync work in executors, not directly on event loops.
  • Keep loop creation and sync-to-async bridging centralized.
  • Define timeout, cancellation, and error mapping policies at boundary points.
  • Add boundary observability so stalls and failures are diagnosable under load.

Course illustration
Course illustration

All Rights Reserved.