6 min read

Async vs Sync in Python and FastAPI – A Beginner-Friendly Guide with Real Insights

Async vs Sync in Python and FastAPI – A Beginner-Friendly Guide with Real Insights

👋 Introduction

If you’re getting started with FastAPI or diving into async/await in Python, you’ve probably asked yourself at some point:

“What’s the point of async?”
“Should I use sync or async?”
“Why are so many FastAPI functions marked as async def?”

You’re not alone 😄

In modern backend development—where handling thousands of requests efficiently and reducing response times is key—understanding the difference between synchronous (sync) and asynchronous (async) programming can help you write faster, more responsive, and resource-friendly applications.

In this post, we’ll walk through:

  • What sync and async really mean, with simple, real-world analogies.
  • How FastAPI natively supports async code.
  • When to use sync vs async in your apps.
  • The most common mistakes developers make with async (and how to avoid them).
  • And finally, we’ll run a mini benchmark to show just how much faster async can be.

Don’t worry if you're not yet familiar with asyncio, event loops, or concurrency—we’ll break it all down in plain, approachable language, with just enough depth to give you a solid understanding.

Ready? Let’s jump in 🚀

Tuyệt! Dưới đây là phần tiếp theo của bài blog: "Sync vs Async explained simply", giữ nguyên tone thân thiện và dễ hiểu 👇


🔁 Sync vs 🔀 Async – Explained the Simple Way

Let’s start with a simple analogy:

Imagine you're at a coffee shop.

Synchronous (Sync) – Waiting in Line

In a traditional coffee shop, the barista serves one customer at a time.
They take the order, make the drink, hand it over, then move on to the next customer.

That’s how synchronous code works:

  • Each task waits for the previous one to finish.
  • Simple and predictable—but not very efficient if tasks take time.
import time

def make_coffee():
    time.sleep(3)
    print("☕ Coffee is ready!")

def sync_barista():
    make_coffee()
    make_coffee()
    make_coffee()

⏱️ In the example above, the barista makes three coffees one after another. Each takes 3 seconds, so you wait a total of 9 seconds.


🔀 Asynchronous (Async) – Place Your Order, We’ll Call Your Name

Now imagine a modern coffee shop:

  • You place your order
  • The barista starts working on it, while taking new orders
  • When your coffee is ready, they call your name

That’s how asynchronous programming works:

  • Tasks that take time (like waiting) don’t block everything else
  • Instead, they say: “I'll come back when I’m done”
import asyncio

async def make_coffee():
    await asyncio.sleep(3)
    print("☕ Coffee is ready!")

async def async_barista():
    await asyncio.gather(
        make_coffee(),
        make_coffee(),
        make_coffee()
    )

⏱️ With async, all three coffees are being “prepared” at the same time. So the total wait is around 3 seconds, not 9!


✅ Summary

Aspect Sync Async
Execution One task at a time Tasks can “wait” in parallel
Good for CPU-heavy, short tasks I/O-bound, network or file I/O
Example time.sleep(3) await asyncio.sleep(3)
Risk Slows everything down Needs proper await handling

Tuyệt vời, mình tiếp tục với phần kế tiếp: "How FastAPI handles Sync and Async" – phần này sẽ bắt đầu đưa người đọc vào thế giới FastAPI một cách nhẹ nhàng nhưng có chiều sâu 👇


⚡ How FastAPI Handles Sync and Async

One of the reasons why FastAPI has gained so much popularity is because it's asynchronous by design.

Unlike older web frameworks like Flask or Django (which are synchronous by default), FastAPI is built on Starlette – a lightweight, high-performance ASGI framework – which means it supports async out of the box.

✨ So what does this mean for you?

In FastAPI, you can define your route handlers using either:

  • def → for synchronous functions
  • async def → for asynchronous functions

Let’s see what this looks like in practice:

from fastapi import FastAPI
import time
import asyncio

app = FastAPI()

@app.get("/sync")
def sync_route():
    time.sleep(2)  # blocking
    return {"message": "This is a sync response"}

@app.get("/async")
async def async_route():
    await asyncio.sleep(2)  # non-blocking
    return {"message": "This is an async response"}

🤔 What’s the difference?

  • In the /sync route, time.sleep(2) blocks the entire event loop. While it's sleeping, FastAPI can't handle any other requests.
  • In the /async route, await asyncio.sleep(2) does not block. While it’s waiting, FastAPI can continue serving other users.

📌 Main points:

  • Use def for simple, fast operations (like in-memory processing).
  • Use async def when you need to wait for something: like a slow database query, an API call, or a file read.

🧪 Want proof? Try it yourself:

  1. Start a FastAPI app with both routes.
  2. Hit /sync 5 times at once → it takes ~10 seconds.
  3. Hit /async 5 times at once → it takes ~2 seconds.

We’ll walk through this test in the Benchmark section later.


⚖️ When to Use Sync vs Async (and Common Pitfalls)

Now that you know how FastAPI handles both sync and async routes, the next question is:

“When should I use async def, and when is def just fine?”

The answer depends on what your code is doing.


✅ Use async when your code is I/O-bound

These are tasks where the program is waiting on something external:

  • Calling an API
  • Querying a database
  • Reading a file from disk
  • Sleeping (literally await asyncio.sleep() 😴)
@app.get("/weather")
async def get_weather():
    async with httpx.AsyncClient() as client:
        res = await client.get("https://api.weatherapi.com/data")
        return res.json()

In these cases, async lets FastAPI handle more requests concurrently, because it’s not stuck waiting around.


🧮 Use sync when your code is CPU-bound

These are tasks that require intensive processing:

  • Image processing
  • Complex math
  • Data transformation in memory
  • Looping over huge datasets

Why? Because Python’s async model does not magically run things in parallel — it just handles waiting better.

If you do heavy processing in an async def, you’ll block the event loop just like you would in sync code.


⚠️ Common Pitfalls (and how to avoid them)

❌ 1. Blocking calls inside async functions

Bad:

@app.get("/bad")
async def bad_route():
    time.sleep(5)  # Blocking!

Good:

import asyncio

@app.get("/good")
async def good_route():
    await asyncio.sleep(5)  # Non-blocking!

❌ 2. Forgetting await when calling an async function

Bad: response = client.get(...)
Good: response = await client.get(...)

You won’t get an error—but the function won’t actually wait, and that can cause bugs.


🧠 Bonus: Calling sync code from async (the safe way)

Sometimes you need to call a legacy or CPU-heavy function from an async route. If you just call it directly, you’ll block everything.

✅ Use FastAPI’s run_in_threadpool() to offload sync work safely:

from fastapi.concurrency import run_in_threadpool

def do_heavy_work():
    time.sleep(3)
    return "done"

@app.get("/safe")
async def safe():
    result = await run_in_threadpool(do_heavy_work)
    return {"result": result}

This runs your sync function in a separate thread without blocking the async event loop.


⏱️ Benchmark: How Much Faster is Async Really?

Theory is great—but nothing beats seeing the difference for yourself.

In this section, we’ll create two simple FastAPI endpoints:

  • One uses sync (time.sleep)
  • The other uses async (await asyncio.sleep)

Then, we’ll hit them with multiple requests and compare how long each takes.


🧪 Step 1: Define the endpoints

# main.py
from fastapi import FastAPI
import time
import asyncio

app = FastAPI()

@app.get("/sync")
def sync_route():
    time.sleep(1)
    return {"message": "sync"}

@app.get("/async")
async def async_route():
    await asyncio.sleep(1)
    return {"message": "async"}

Each endpoint simulates a 1-second delay.


🚀 Step 2: Run the app

uvicorn main:app --reload

🔁 Step 3: Test with multiple requests

We’ll send 10 requests at the same time using Python.

# benchmark_test.py
import asyncio
import httpx
import time

async def run_benchmark(path: str):
    url = f"http://localhost:8000/{path}"
    async with httpx.AsyncClient() as client:
        start = time.perf_counter()
        tasks = [client.get(url) for _ in range(10)]
        responses = await asyncio.gather(*tasks)
        end = time.perf_counter()

    print(f"{path.upper()} - Time taken: {end - start:.2f} seconds")

async def main():
    await run_benchmark("sync")
    await run_benchmark("async")

asyncio.run(main())

📊 Expected Output

SYNC - Time taken: 10.20 seconds
ASYNC - Time taken: 1.25 seconds

😲 Why the big difference?

  • In the sync version, each request waits in line (like a queue at the coffee shop).
  • In the async version, all requests wait together using cooperative scheduling.

That’s the real power of async in I/O-bound tasks.


Mode Requests Total Time (Approx) Efficiency
Sync 10 ~10 seconds 😴 Slow
Async 10 ~1 second 🚀 Fast

📌 Final Thoughts

Async programming in Python may feel intimidating at first—but with FastAPI, it’s easier (and more powerful) than ever.
Once you get comfortable with async def and await, you’ll unlock the ability to build faster, more scalable, and modern web applications.

Whether you’re building a hobby project or a production-ready API, using async correctly can make a huge difference in performance 🚀

Thanks for reading, and happy coding! 🙌


🔗 Bonus Resources