Building an asynchronous HTTP load tester with Python asyncio
When your team runs a popular Australian e-commerce platform and Black Friday sales are looming, knowing how your checkout API behaves under a flood of visitors becomes non-negotiable. A Melbourne-based retailer I worked with once discovered their payment endpoint buckled under 200 concurrent shoppers, costing the business hours of lost revenue. Load testing answers those questions before customers do, and Python's asyncio gives you the asynchronous machinery to simulate thousands of concurrent calls without spinning up a forest of threads.
asyncio has matured into a serious contender for network-bound workloads, where waits rather than computation dominate. By yielding control during I/O, a single event loop can juggle hundreds of in-flight requests, mirroring real user behaviour. Whether you're benchmarking an API hosted in the AWS Sydney region or stress-testing an internal microservice, the patterns below will give you a reusable foundation.
Designing the test scenario and target mix
Before writing any code, spell out what success looks like for your load test. Are you probing a checkout endpoint to validate a surge during a Boxing Day sale, or measuring how an API behaves when a journalist publishes a story that drives curious readers to your site? Define the endpoint, the request method, the headers, and the body shape, because shortcuts here produce misleading numbers.
Mock data matters as well. Generating a thousand near-identical requests gives you a worst-case cache hit rate, while a randomised payload set stresses the database layer more realistically. For an Australian audience, that might mean rotating through postcodes drawn from Sydney, Melbourne, and regional towns like Ballarat to mimic a realistic geographic spread. The clearer the scenario, the easier it becomes to interpret anomalies later.
Choosing the right async HTTP client
Two libraries dominate the Python ecosystem for async HTTP work: aiohttp and httpx. Both expose coroutine-based methods, but they differ in ergonomics. aiohttp has been around longer, ships its own client session, and remains a favourite for high-volume scraping and load generation. httpx offers a familiar requests-like API and built-in HTTP/2 support, which can sometimes squeeze more performance from a single connection through multiplexing.
For a load tester, lean toward aiohttp when raw throughput is the priority and your target speaks plain HTTP/1.1, still the default for many Australian government endpoints. If you need HTTP/2 because you're probing a modern CDN or a SaaS API served from Sydney, httpx makes the switch painless. Either way, the calling pattern looks similar: open a client, await the response, inspect the status, and move on.
Constructing the worker coroutine
A worker is a coroutine that performs one request and reports back. Keep it small and predictable. It should accept a session, a target URL, and a placeholder for results. Inside, wrap the call in a try block, measure the wall-clock duration with time.perf_counter, and record whatever signals you care about: HTTP status, response size, and any exception raised.
The cleanest way to share state across workers is a lightweight container such as a dictionary keyed by status code, or a simple dataclass that accumulates counts and latencies. Because asyncio runs everything on one thread, you don't need locks for primitive operations. Updates to simple counters stay safe as long as you avoid await statements between read and write. The result reads like synchronous Python, a relief when debugging a flaky run during a Sydney outage.
Bounding concurrency with semaphores
Unconstrained concurrency can exhaust file descriptors, overwhelm the target server, or get your IP address throttled by upstream providers such as Aussie Broadband or Telstra. An asyncio.Semaphore acts as a turnstile, capping the number of in-flight calls to a value that matches your test goals. Wrap each worker in async with semaphore: and the loop will politely queue the rest.
Expose the cap as a command-line argument so you can re-run the same script at different pressure levels. Pair the semaphore with asyncio.gather to launch a batch of tasks and wait for the entire cohort to finish, returning aggregated metrics in one go. For longer campaigns, swap gather for an asyncio.Queue fed by a producer, sustaining a steady request rate rather than a single spike. That distinction matters when testing systems that warm caches gradually, such as a media site streaming AFL highlights.
Gathering latency and throughput metrics
Raw counts of success and failure tell only part of the story. Latency percentiles reveal whether ninety-nine out of a hundred users enjoyed a snappy page while one unlucky soul waited ten seconds. Capture each response time, push it into a list, and feed the list to statistics.quantiles once the run finishes. The p50, p95, and p99 figures give a more honest picture than a simple average, especially when network jitter from the nbn introduces occasional spikes.
Throughput is measured in requests per second. Divide the total number of completed calls by the elapsed wall-clock time of the run, and you'll have an apples-to-apples comparison across configurations. Some testers also track bytes per second, useful when piping large payloads between data centres. Logging everything to a CSV or to stdout in a tab-separated format lets you drop the output straight into a spreadsheet for a quick chart, which your project manager in Brisbane will appreciate ahead of the standup.
Reporting results and feeding them back
Numbers without context don't change behaviour. Summarise each run with a plain-English line such as "5,000 requests in 30 seconds, p95 of 412 ms, no timeouts," then keep the raw numbers alongside. Wire the load tester into a CI pipeline so it fails the build when p95 exceeds a threshold or error rates would page the on-call engineer in the small hours.
Visualising the response-time distribution as a histogram makes regressions obvious at a glance. Tools such as matplotlib or the lighter-weight plotext produce serviceable charts from the command line; for richer dashboards, push the metrics into Prometheus and graph them in Grafana alongside production traffic from Australian customers. Either approach turns a one-off script into a regression detector that catches problems before they reach users.
Putting it to work against Australian infrastructure
A practical first target is the public API of the Bureau of Meteorology, which serves forecasts for hundreds of locations. Run the tester against it from a server in the AWS Sydney region and you'll measure round-trip times that reflect realistic conditions for users in Newcastle or Hobart. Move the source machine to a different provider or to a home connection on the nbn and the script reveals how geography and last-mile infrastructure shift the numbers.
Another exercise is hitting your own staging environment from an external location, since internal-only tests miss the latency that real visitors incur. Configure the script to ramp from low to high concurrency over a few minutes, watch the latency curve bend, and you've discovered your effective concurrency limit. Save those measurements alongside deployment notes, and the next time a team in Perth pushes a refactor, you'll have a baseline to compare against.
