Module 7: Concurrency, Async, Working at Scale
What you'll learn in this module
By the end of this module, you'll be able to:
- Explain what concurrency actually means, and why it's different from doing things faster
- Read and write basic
async/awaitPython code, and explain what the event loop is actually doing when your code runs - Explain why unbounded concurrency can work perfectly in testing and fail badly in production, with no code changes in between
- Bound concurrent work with a semaphore, and explain what number to actually choose and why
- Process a dataset far larger than any single batch should handle, using a generator that never loads it all into memory at once
- Actually profile a concurrency fix — before and after — instead of assuming it worked
- Spot the single most common AI mistake with async code: unbounded fan-out with no limit at all
This module assumes you've never written async Python before. If
you have, some of the early material will move fast — that's fine,
the real content starts once the vocabulary is out of the way.
Let's begin.
Let's start with a question
Imagine you're cooking dinner, and it involves boiling pasta and making a sauce. Do you boil the pasta, wait, staring at the pot until it's done, and only then start the sauce? Or do you start the water, and while it's heating up — which takes a while, and needs none of your attention — you start chopping vegetables for the sauce?
Almost nobody actually cooks the first way. Waiting idly for water to boil, when you could be doing something else useful during that exact wait, feels obviously wasteful the moment you picture it.
Here's the thing: a huge amount of code written by data engineers does exactly the "stare at the pot" version of this, every single day — fetching from one API, waiting, then the next, waiting, then the next — when most of that time isn't the CPU doing work at all. It's just waiting. This module is about not staring at the pot.
Why this matters in data engineering
Ask yourself something concrete: when your code calls an external API and waits for a response, what is your computer actually doing during that wait?
The honest answer, almost always: nothing. The network request went out, and your program is just sitting there, doing no computation at all, until a response comes back. This is called being I/O-bound — bound by the speed of input/output (network, disk, a database), not by how fast your CPU can compute something.
Data engineering work is full of exactly this. Fetching pages from a paginated API. Querying a database. Writing to a warehouse. Calling a notification service. In almost every one of these, your program spends far more time waiting than computing.
Here's why that matters so much: if you fetch fifty things one at a time, and each fetch takes 200 milliseconds of pure waiting, you've just spent ten full seconds doing essentially nothing — one wait after another, in sequence, when nothing about those fifty requests actually depended on each other. If you could somehow start many of those waits at the same time, the total time wouldn't be the sum of all the waits — it would be close to the length of the single longest one. That difference, at real scale, is the difference between a pipeline that takes ten seconds and one that takes hundreds.
The core idea: doing many things at once, without needing more CPUs
Concurrency is not the same as parallelism
Parallelism means doing multiple things at the exact same instant — this genuinely requires multiple CPUs, or multiple cores, each doing real work simultaneously.
Concurrency means managing multiple things that are in progress at once, even on a single CPU — by intelligently switching between them whenever one of them is waiting instead of working. The chef doesn't need two pairs of hands to start water boiling and then chop vegetables. One chef, one pair of hands, but two things "in progress" at once, because most of the water's boiling time needed zero attention.
This distinction matters because it tells you why this module reaches
for async instead of, say, running things on multiple threads or
processes. Threads and processes are real tools, useful especially for
CPU-bound work — heavy computation that genuinely needs to happen in
parallel. But they come with real costs: each thread needs its own
memory and scheduling overhead, and Python's own internals limit how
much true CPU parallelism threads can actually achieve anyway. For
I/O-bound work — mostly waiting, not computing — async gives you a
much cheaper way to have thousands of things "in progress" at once, all
on a single thread, because waiting, in async code, costs almost
nothing.
The building blocks: async, await, and the event loop
Let's build this up from nothing, with the smallest possible example.
async def declares a function as a coroutine function. Calling
it doesn't run its body immediately — it hands you back a coroutine
object, like a recipe you haven't started cooking yet.
await is what actually runs a coroutine — and critically, if that
coroutine is waiting on something (like a network call), await lets
your program go do other useful work during that wait, instead of
freezing until it's done.
The event loop is the thing coordinating all of this — it's what actually decides "this coroutine is waiting, let me go run a bit of that other one instead," switching between many in-progress coroutines as they alternate between working and waiting.
asyncio.run(...) is how you actually start the event loop and
run your top-level coroutine — the entry point into all of this.
Here's the smallest possible demonstration. Two fake fetches, each taking one second — run one after another first:
pythonimport asyncio import time async def fake_fetch(name: str, seconds: float) -> str: print(f"Starting {name}...") await asyncio.sleep(seconds) print(f"Finished {name}") return name async def main() -> None: start = time.perf_counter() await fake_fetch("A", 1) await fake_fetch("B", 1) print(f"Total: {time.perf_counter() - start:.2f}s") asyncio.run(main())
Run this, and you'll see it take about 2 seconds — A starts and
finishes completely, then B starts. Nothing concurrent happened at
all; await here just ran one coroutine fully before moving to the
next.
Now change exactly one thing — run them together, with
asyncio.gather:
pythonasync def main() -> None: start = time.perf_counter() await asyncio.gather( fake_fetch("A", 1), fake_fetch("B", 1), ) print(f"Total: {time.perf_counter() - start:.2f}s")
This takes about 1 second, not 2 — and if you watch the printed
output, you'll see Starting A and Starting B both happen before
either one finishes. Both fake fetches were "waiting" on their
asyncio.sleep at the same time, on the same single thread, because
neither one needed the CPU during that wait — the event loop simply
let them both be in progress simultaneously.
That's the entire mechanism this module builds on. Everything below is this same idea, applied to a real, larger problem.
The real problem: unbounded fan-out
asyncio.gather(*[some_async_call(x) for x in huge_list]) schedules
every single one of those calls essentially at once. For ten items,
that's fine. For forty thousand, you're not making forty thousand
polite requests — you're launching what looks, from the other side,
indistinguishable from a burst attack.
Here's the part that makes this bug genuinely dangerous: it doesn't show up in small-scale testing. Staging environments, sample data, quick manual runs — all of these tend to use far fewer items than production actually sees. The bug is invisible below a certain scale, and guaranteed above it. That's exactly why it survives code review and testing, and only shows up for real once it's already live.
Bounding with a semaphore
An asyncio.Semaphore(n) limits how many pieces of code can be inside
a specific block at the same time, to at most n. Wrap each concurrent
call in async with semaphore:, and no matter how many tasks you
schedule, only n of them are ever actually running at once — the rest
wait their turn.
The number you choose matters. Too low, and you're needlessly slow — barely better than sequential. Too high, and you're back to overwhelming whatever you're calling. The right number is tied to the actual capacity of what you're calling, not a round number that felt reasonable.
A different problem: too much to even schedule at once
Here's something the semaphore doesn't fix. asyncio.gather(*tasks)
needs the full list of tasks before it runs any of them — even
bounded by a semaphore, Python still has to create every single task
object up front. For fifty items, that's nothing. For a catalog of
half a million, creating half a million task objects before a single
one has even started running is a real memory cost, entirely separate
from the concurrency problem the semaphore already solved.
The fix: chunking with a generator
The fix is to never hold the whole dataset's worth of work in memory at once. A generator can yield one manageable chunk at a time — say, fifty items — and only that chunk's tasks get created and run, before moving to the next chunk. The dataset itself can even be a generator too — reading from a file, a database cursor, a paginated API — so at no point does the full dataset exist in memory as one object.
This module's lab implements this directly: a small batch uses bounded concurrency alone, and a much larger catalog uses chunking and bounded concurrency together — two different techniques, solving two different problems, stacked on top of each other.
Manual lab: fast and wrong vs. fast and right, at real scale
Getting the lab files
Download module-7-materials.zip:
bashcd ~/courses/pp4de cp /mnt/c/Users/yourname/Downloads/module-7-materials.zip course-materials/ cd course-materials unzip module-7-materials.zip
Commit it:
bashcd ~/courses/pp4de git add course-materials/module-7 git commit -m "Add Module 7 lab materials" git push
The scenario
You've inherited a tool that checks current prices for a batch of SKUs against a pricing service. The service has a real limit — it can only handle 5 requests at the same time before it starts rejecting them. Your predecessor tested this tool with a handful of SKUs and it worked fine. Today, someone runs it against the full catalog — fifty SKUs.
Reproduce the problem first
- Go into the starter folder:
bash
cd course-materials/module-7/starter - Run it:
bash
python3 run.py - Watch the output. You should see something like
Priced 5 of 50 SKUs in 0.05s, with 45 failure lines above it, all saying roughly the same thing — too many requests in flight at once. - Open
price_checker.py. Findasyncio.gather(*tasks, ...). Notice there's no limit anywhere on how many of those tasks actually run at the same time — all fifty get scheduled essentially at once. - Open
pricing_api.pyand read the comment at the top. This service genuinely can't handle more than 5 requests simultaneously — that's not a made-up restriction, it's meant to represent a real, documented capacity limit, the kind every real external service has.
Notice something worth sitting with: this ran fast — 0.05 seconds. Fast isn't the problem. Only pricing 5 out of 50 SKUs, silently, is the problem.
Your task
Fix this in two stages.
Stage 1 — bound the concurrency, so that:
- No more than a safe number of requests are ever in flight at the same time — comfortably under the service's real limit of 5.
- All 50 SKUs get priced successfully.
- It's still meaningfully faster than pricing them one at a time.
Stage 2 — handle a much bigger catalog. Imagine the real catalog
isn't 50 SKUs, but 500 (or more). Write a generator that yields SKUs in
chunks, and process the catalog chunk by chunk, using your bounded
check_all_prices within each chunk. Prove — don't just assume —
that this never holds the whole catalog's tasks in memory at once.
A question worth asking before you look at the solution
Same habit as every module. If the solution looks different, ask why.
Here's the honest answer: the core idea — fetch a price for a SKU — is identical throughout. What's added is entirely about pacing, in two separate layers: a semaphore controlling how many fetches run at once, and a generator controlling how many fetches even get created at once. Neither one changes what a single fetch does.
Full worked solution
The complete solution lives in course-materials/module-7/solution/:
solution/
├── pyproject.toml
├── run.py
└── src/
└── pricecheck/
├── __init__.py
├── pricing_api.py # unchanged - the simulated external service
├── price_checker.py # stage 1: a semaphore bounding concurrency
└── catalog.py # stage 2: chunked, generator-based processing
price_checker.py — stage 1, the semaphore:
pythonimport asyncio from pricecheck.pricing_api import fetch_price MAX_CONCURRENT_REQUESTS = 4 async def _fetch_one(semaphore: asyncio.Semaphore, sku: str) -> tuple[str, float | Exception]: async with semaphore: try: price = await fetch_price(sku) return sku, price except Exception as e: return sku, e async def check_all_prices(skus: list[str]) -> dict[str, float]: semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) tasks = [_fetch_one(semaphore, sku) for sku in skus] results = await asyncio.gather(*tasks) prices: dict[str, float] = {} for sku, result in results: if isinstance(result, Exception): print(f"Failed to price {sku}: {result}") else: prices[sku] = result return prices
Notice MAX_CONCURRENT_REQUESTS = 4 — deliberately below the service's
actual limit of 5, leaving a small safety margin.
catalog.py — stage 2, the new piece this module adds:
pythonfrom collections.abc import Iterable, Iterator from pricecheck.price_checker import check_all_prices CHUNK_SIZE = 50 def iter_sku_chunks(skus: Iterable[str], chunk_size: int = CHUNK_SIZE) -> Iterator[list[str]]: """Yields successive chunks of `chunk_size` SKUs, one at a time. `skus` can itself be a generator - this never materializes the whole catalog into memory, whether it's 500 SKUs or 500,000. """ chunk: list[str] = [] for sku in skus: chunk.append(sku) if len(chunk) == chunk_size: yield chunk chunk = [] if chunk: yield chunk async def check_full_catalog( skus: Iterable[str], chunk_size: int = CHUNK_SIZE ) -> dict[str, float]: """Processes an entire catalog, chunk by chunk. Within each chunk, check_all_prices still applies its own bounded concurrency - the two techniques stack, they don't replace each other. """ all_prices: dict[str, float] = {} for chunk_number, chunk in enumerate(iter_sku_chunks(skus, chunk_size), start=1): print(f"Processing chunk {chunk_number} ({len(chunk)} SKUs)...") chunk_prices = await check_all_prices(chunk) all_prices.update(chunk_prices) return all_prices
Notice iter_sku_chunks is a generator function — it uses yield,
not return. That's what makes it lazy: it only produces the next
chunk when something actually asks for it, so the full catalog is
never sitting in memory as one object, no matter how large it is.
Verify it, step by step — with real proof, not assumptions
- Create a virtual environment and install:
bash
cd course-materials/module-7/solution python3 -m venv .venv source .venv/bin/activate pip install -e . pip install mypy - Run
mypy --strict:
Expected:bashpython -m mypy --strict src/pricecheck/ run.pySuccess: no issues found. - Run it:
You should seebashpython run.pyPriced 50 of 50 SKUsfor the first batch, then ten chunk-progress lines, ending inPriced 500 of 500 catalog SKUs. - Now prove the chunking is genuinely lazy — not just "happens to
work." Run this:
If this showsbashpython3 -c " from pricecheck.catalog import iter_sku_chunks generated_count = 0 def tracked_catalog(n): global generated_count for i in range(n): generated_count += 1 yield f'SKU-{i:05d}' chunks = iter_sku_chunks(tracked_catalog(1000), chunk_size=50) next(chunks) print(f'After ONE chunk: {generated_count} generated (should be 50, not 1000)') next(chunks) print(f'After TWO chunks: {generated_count} generated (should be 100, not 1000)') "50and then100— not1000both times — you've proven the generator never materializes the whole catalog, exactly the claim this module makes.
If step 3 succeeded and step 4 showed genuinely lazy generation, you've verified both halves of what this module set out to teach.
AI-assisted round
The task
Ask your assistant to extend the solution:
Output / Note"Add a function that also fetches each SKU's current stock level from a second, similar service,
check_stock_levels, for the same list of SKUs, at the same time as pricing. Write a test for it, and run it before telling me you're done."
The known failure pattern to watch for
This is the exact bug from this module's opening story: an AI
assistant reaching for asyncio.gather on a new fan-out, without
reusing the concurrency bound you already established.
Here's what that tends to look like:
diff+ async def check_stock_levels(skus: list[str]) -> dict[str, int]: + tasks = [fetch_stock_level(sku) for sku in skus] + results = await asyncio.gather(*tasks) + return dict(zip(skus, results))
This runs fine in a quick test with a handful of SKUs — the same reason the original bug survived staging. At real scale, against a real service with a real capacity limit, this reintroduces exactly the problem this module already fixed, in a second function sitting right next to the first one that correctly uses a semaphore.
The guardrail
If you catch this, write it down:
Output / NoteModule 7 guardrail: Any time an AI assistant adds a new
asyncio.gathercall over a list of items, check specifically whether it's bounded. If a semaphore pattern already exists elsewhere in the project, check whether the new code reuses it — a second, unbounded fan-out function is easy to miss in review because it looks like ordinary async code, and it will pass any test that doesn't exercise it at real scale.
If your assistant reused the existing bounded pattern without being told to — log that too.
Common mistakes
- Forgetting
await. Calling anasync deffunction withoutawaitdoesn't run it — it just gives you back an unused coroutine object, silently. Python will usually warn about this ("coroutine was never awaited"), and it's worth taking that warning seriously the first time you see it, not dismissing it. - Calling a blocking function inside an
async def. A genuinely slow, non-async call (liketime.sleepinstead ofasyncio.sleep, or a synchronous database driver) freezes the entire event loop while it runs — not just the coroutine that called it. This defeats the entire point of using async in the first place. asyncio.gatherwith no limit at all. Works perfectly at small scale. Guaranteed to cause real problems once the input size grows enough — and "enough" is often exactly what production looks like and testing doesn't.- Setting the concurrency limit right at the documented capacity, with no margin. Leaves zero room for the real world being slightly less generous than the documentation.
- Loading an entire large dataset into memory before processing any of it, when a generator would let you start immediately and use a fraction of the memory — and not proving it's actually lazy, just assuming a generator automatically means "memory-safe."
Capstone tie-in
Step 1: Open your capstone repo and start the infrastructure
Confirm the title bar says pp4de [WSL: Ubuntu].
bashdocker compose start docker compose ps
Step 2: Bound your capstone's concurrent page fetches
In VS Code's Explorer:
- Right-click
pipeline(insidesrc/) → New File → name itfetcher.py, if you don't already have one from earlier modules. - Write an async function that fetches multiple pages from the mock API concurrently, bounded by a semaphore — the same pattern as this module's lab, applied to real pages of records instead of SKUs. Remember Module 3's retry logic and Module 6's structured logging — this is a good place to use both together.
Step 3: Add a chunked, generator-based transform
Wherever your capstone processes a batch of validated records (from
Module 2's models), rewrite it — or write it fresh — as a generator
that yields transformed records one at a time, the same shape as this
module's iter_sku_chunks. Prove it's genuinely lazy the same way the
lab did — track how many records get pulled through before the first
chunk is consumed, and confirm it's not the whole dataset.
Step 4: Profile it for real — the one mandatory terminal step
Open the integrated terminal, confirm your venv is active, and measure
your bounded fetch against a real, larger date range from the mock API
— time it, the same way this module's lab did, and write down the
actual number. Compare it against what a naive sequential fetch of the
same pages would cost, using time.perf_counter() around both.
Step 5: Commit and push through VS Code's Source Control panel
- Click the Source Control icon in the sidebar.
- Confirm
.venvis not listed under Changes. - Stage, commit with a message like
Module 7: bounded concurrent fetch, chunked transform, profiled, and click Commit. - Click Sync Changes to push to GitHub.
- Confirm the changes appear on GitHub.
Check row 7 off your SPEC.md checklist. Two to go.
Before you close for the day:
bashdocker compose stop
Interview drill
Every question below follows the same pattern. First, the question. Then, what the interviewer wants to hear. Then, a junior engineer's answer. Then, a strong senior answer.
Recall
Question: "What's the actual difference between concurrency and
parallelism? And why does Python's asyncio help with I/O-bound work
specifically, rather than CPU-bound work?"
What the interviewer wants to hear: a precise distinction between the two terms, and a real explanation of why async specifically suits waiting-heavy work, not just "it's faster."
Junior answer: "Concurrency and parallelism are basically the same thing — doing multiple things at once. Async makes code run faster."
(Conflates two genuinely different concepts, and "makes code run faster" isn't accurate or specific — async doesn't speed up computation at all.)
Senior answer: "Parallelism is doing multiple things at the exact
same instant, which needs multiple CPUs actually working
simultaneously. Concurrency is managing multiple things that are in
progress at once, by switching between them — which doesn't require
more than one CPU, as long as most of the 'in progress' time is spent
waiting, not computing. asyncio specifically helps with I/O-bound
work — network calls, database queries — because during those waits,
the CPU has nothing to do anyway, so switching to another task costs
almost nothing. It wouldn't help nearly as much with CPU-bound work,
like heavy number crunching, because there's no idle waiting time to
fill with other tasks in the first place — that's a genuinely different
problem, better suited to multiprocessing."
Debugging
Question: shown as a real snippet and incident story, with no explanation yet:
pythonimport asyncio import aiohttp async def fetch_player_stats(session: aiohttp.ClientSession, player_id: str) -> dict: async with session.get(f"https://stats.internal/players/{player_id}") as resp: return await resp.json() async def refresh_leaderboard(player_ids: list[str]) -> list[dict]: async with aiohttp.ClientSession() as session: tasks = [fetch_player_stats(session, pid) for pid in player_ids] results = await asyncio.gather(*tasks) return results
"This runs fine in staging, where tournaments have a few hundred
players. In production, a tournament with 40,000 players caused this
function to start throwing connection errors partway through, and
separately, the downstream stats.internal service had its own
on-call paged for what looked like a traffic spike attack. No code
changed between staging and prod. What's actually going on, and why
did staging never catch it?"
What the interviewer wants to hear: identifying unbounded concurrency as the root cause — not vague guesses like "the network is unreliable" — and explaining precisely why staging's smaller scale hid the bug.
Junior answer: "Maybe aiohttp isn't handling that many requests
well, or the network had some issue at that scale."
(Guesses at plausible-sounding external causes instead of examining what the code itself actually does at scale.)
Senior answer: "asyncio.gather schedules every single task here
essentially at once — for 40,000 players, that's 40,000 requests fired
close to simultaneously, with nothing bounding how many are actually in
flight. That exhausts the calling client's own connection pool, which
explains the connection errors on this side. From stats.internal's
point of view, a burst of 40,000 near-simultaneous requests looks
indistinguishable from a traffic spike attack, which explains their
on-call getting paged separately. Staging never caught this because a
few hundred players never exposed the missing bound — this bug scales
with input size, so it's invisible below some threshold and guaranteed
above it, and a functional test with realistic-looking but small data
will never catch that. The fix is bounding concurrency with a
semaphore, sized to what stats.internal can actually handle, not an
arbitrary round number."
AI-review
Question: shown this diff, with no explanation yet:
diff+ async def check_stock_levels(skus: list[str]) -> dict[str, int]: + tasks = [fetch_stock_level(sku) for sku in skus] + results = await asyncio.gather(*tasks) + return dict(zip(skus, results))
The project already has an established, working pattern for bounded
concurrency — a semaphore, used consistently in check_all_prices for
a very similar kind of fan-out.
What the interviewer wants to hear: recognizing that this new function doesn't reuse the existing bounded pattern, and explaining why that's a real risk, not just a style inconsistency.
Junior answer: "It's a bit inconsistent with the other function's style, but functionally it should work fine for fetching stock levels."
(Notices the inconsistency but treats it as cosmetic, not a real correctness or reliability risk.)
Senior answer: "This is the exact same unbounded fan-out problem
the rest of this project already solved, reintroduced in a new
function. asyncio.gather(*tasks) here has no semaphore at all — every
SKU's stock level gets fetched essentially simultaneously, with nothing
bounding it. It'll probably work fine in a quick test with a handful of
SKUs, which is exactly why this kind of bug survives review — it looks
identical to correct async code until it runs at real scale. I'd tell
the assistant to reuse the same bounded pattern as check_all_prices —
wrap each call with the existing semaphore, or a new one sized to
whatever service fetch_stock_level actually calls."
Judgment
Interviewer: "You're the on-call engineer for a logistics platform that ingests scan events from warehouses and delivery vehicles, to update the live status of packages in transit. During a peak shipping day, the ingestion service starts falling behind — events are queuing up faster than they're being processed. Your team lead proposes removing the concurrency limit on the downstream status-update calls entirely, to 'let it process as fast as it possibly can.' What do you say?"
Candidate (asking first): "Before I answer — do we know what's actually rate-limiting throughput right now? Is the downstream status-update service itself the bottleneck, or is something upstream of it, like the event queue itself, actually the constraint?"
Interviewer: "Good question — we've confirmed the downstream status-update service is the bottleneck. It has a documented capacity, and our current concurrency limit is already set right at that documented number."
Candidate (first answer): "Then I'd push back on removing the limit entirely — that's very likely to make things worse, not better. If we're already at the service's documented capacity, removing the limit doesn't unlock more real throughput, since the service still physically can't process more than it can process. What it will do is start generating errors and retries once we exceed that real capacity, which could easily make our effective throughput worse than what we have now, not better."
Interviewer (pushback): "But we're falling behind right now — isn't doing something drastic better than watching the queue keep growing?"
Candidate (round 1): "I understand the urgency, but 'drastic' and 'effective' aren't the same thing here. If the downstream service is genuinely the bottleneck, unbounding our concurrency doesn't increase its actual capacity — it just means we start hitting that capacity limit with errors and retries instead of hitting it cleanly. That usually makes backlogs worse, not better."
Interviewer (second pushback): "So what would you actually propose to relieve the backlog today?"
Candidate (round 2): "A few things, in order of how fast they could help. First, confirm our current concurrency limit is actually optimally tuned — right at documented capacity might not be the same as actual current real-world capacity, especially under today's unusual peak load; there might be a little real headroom to find carefully, by testing a modest, deliberate increase and watching error rates closely. Second, I'd look at whether we can reduce the number of status-update calls needed in the first place — batching multiple scan events per package into a single downstream update, if the API supports it, instead of one call per individual event."
Interviewer (final challenge): "Your team lead still thinks removing the limit is the fastest fix. Convince them otherwise, directly."
Candidate (final defense): "I'd frame it around what 'fast' actually means here. Removing the limit feels fast because it's a one-line change, but if the downstream service is genuinely at capacity, the real-world result is more errors, more retries, and likely a worse effective throughput than we have right now. A careful, measured increase to the limit, backed by watching real error rates, or reducing the number of calls we need to make at all through batching, both directly address the actual bottleneck. Those take a little more thought than deleting a limit, but they're actually likely to help, instead of turning a backlog into a backlog plus a wave of failed requests."
Model strong answer — a single answer, given upfront:
Output / Note"I'd want to confirm first whether the downstream status-update service is actually the bottleneck, or whether something upstream is — assuming it's confirmed the downstream service is at its documented capacity, I'd push back strongly on removing the concurrency limit entirely. If that service genuinely can't process more than it can process, removing the limit doesn't create more real capacity — it just means we start hitting that same limit with errors and retries instead of hitting it cleanly, which typically makes effective throughput worse, not better, during exactly the peak load we're trying to survive. I'd propose two things instead: cautiously testing whether the documented capacity has a little real headroom under today's specific conditions, adjusted carefully and watched closely for error rates — and separately, looking at whether scan events can be batched into fewer downstream calls per package, which reduces actual load on the bottleneck rather than just changing how hard we push against it."