EXPERIMENT / CONNECTION POOL

Do More Database Connections Make Queries Faster?Less connection wait, but no faster overall operation

Should you enlarge a pool when database connection wait appears? We tested Postgres with 5, 20, and 100 connections and show what to compare before and after changing the setting.

When requests arrive together, they can have to wait before a query even starts because no database connection is available. Increasing the connection pool—the set of database connections kept for reuse—looks like a way to reduce that wait.

But reducing connection wait is not the only question. Will the time until query results arrive get shorter too? To find out, we used the same Postgres database with 5, 20, and 100 connections and measured the time before and after borrowing a connection separately.

In the experiment, sending 10 queries together with 5 connections produced a mean wait of 22.1ms to borrow a connection. With 20 connections, that wait was almost zero. Yet the mean time for the whole operation, including returning the connection, grew from 72.3ms to 95.7ms.

Under these conditions, less connection wait did not by itself mean the whole operation had improved. Below, we examine which intervals grew and explain what to record before and after changing connection counts in your own application.

We sent a fixed number of requests to a small database limited to 0.5 CPU core. This does not establish maximum capacity or an optimal connection count for every environment.

Measure before and after borrowing a connection separately

A task running a search or other query borrows a connection before running a query and returns it afterward. If none is available, it waits to borrow one. The official asyncpg documentation shows this acquire-and-release pattern.

We used Python's asyncpg and pool limits of 5, 20, and 100. In every condition, we established that full number of connections in advance. The measurement does not include opening a new connection for each request.

The database's max_connections stayed at 110 in every condition. That is the maximum number of concurrent connections Postgres accepts, a different setting from the application pool limit varied here. See the PostgreSQL connection settings.

FIG. 01 / CONCEPTAcquiring a connection is not the end of the measurement
Conceptual, not drawn to measured scale. Overall time is the sum of these four intervals. There is no HTTP server: one SQL execution plus pool acquisition and release counts as one request.

We defined overall time as the interval from the scheduled send time until connection release completed. We also recorded when a connection was acquired and when the SQL result arrived. Release includes asyncpg's standard connection reset.

“Query round trip” is elapsed time measured on the client. It is not pure SQL execution time inside the database: it includes database work and waiting, as well as client-side handling of the result.

Acquisition wait almost disappeared, but the overall mean grew

We list the mean times of the four intervals for batches of 10. The overall time and each interval are averages over the same requests.

Mean time with bursty arrivals 5 connections 20 connections 100 connections
Scheduled send to task start 1.7ms 2.0ms 2.2ms
Pool acquisition wait 22.11ms 0.03ms 0.03ms
Query round trip 38.4ms 79.9ms 80.3ms
Pool release 10.1ms 13.8ms 13.8ms
Overall 72.3ms 95.7ms 96.4ms
Mean times for bursty arrivals: with 5 connections, acquisition wait is 22.1ms, query round trip 38.4ms, and overall time 72.3ms. With 20, acquisition wait is about 0.03ms, query round trip 79.9ms, and overall time 95.7ms. With 100, acquisition wait is also near zero and overall time is 96.4ms.
Measured means across all requests, stacked by interval. Acquisition wait improves, but overall time does not shrink. Rounded values in the table and chart may not add up exactly.

With 5 connections, some of the 10 simultaneous requests wait before borrowing a connection. With 20 or 100, all 10 can borrow one, and that wait almost disappears. Meanwhile, the mean query round trip grows from about 38ms to 80ms. Including release, the overall mean grows as well.

The measurement shows that less acquisition wait and more time after acquisition were observed together. It does not mean the 22ms saved simply moved elsewhere. Nor can these records separate how many milliseconds were spent waiting in each part of the query round trip.

More connections allow more work to be sent to the database. In this experiment, however, we did not increase the total CPU available to it. The number of requests allowed to proceed and the amount of computation that can actually progress are separate questions.

Send three workloads to the same database

We prepared 20,000 rows, about 3.8MiB including the table and index. The light query fetches one row by primary key. The heavy query computes MD5 hashes of strings across 2,000 rows and aggregates the result. “Heavy” describes a comparison workload involving computation, not a reproduction of a production query.

-- Light query
SELECT balance FROM pool_lab WHERE id = $1;

-- Heavy query
SELECT sum(length(md5(payload || id::text)))
FROM pool_lab
WHERE id BETWEEN $1 AND $1 + 1999;

We used the following three arrival patterns, scheduling requests in advance rather than waiting for each response before sending the next.

Condition Scheduled arrivals Measurements per pool setting
Light query, uniform arrivals 1 every 5ms, 200/sec 7,200
Heavy query, uniform arrivals 1 every 25ms, 40/sec 1,440
Heavy query, bursty arrivals 10 every 250ms, 40/sec 1,440

The two heavy-query conditions use the same SQL and mean request rate. What changes is whether requests arrive in short bursts. The light and heavy queries have different arrival rates, so we do not use them to calculate a performance multiplier between queries.

The entire database was limited to 0.5 CPU core, with a separate 0.5-core limit for the load generator. The quota period was 10ms. After executing the SQL on each connection in advance, we warmed up for 2 seconds and measured three 12-second runs per condition. Pool order was varied across the 27 runs, and all 30,240 requests completed.

Compare slower responses and bursts of arrivals too

Averages can make it difficult to see delays affecting some responses. We therefore also compare response times at the slower end. For example, with 100 observations sorted from fastest to slowest, this is the time of the 99th response. The table below pools all three runs of each condition. This is a different measure from the means above.

Arrival condition 5 connections 20 connections 100 connections
Light, uniform 200/sec 1.90ms 1.88ms 1.94ms
Heavy, uniform 40/sec 11.22ms 14.69ms 13.63ms
Heavy, batches of 10 at 40/sec 125.98ms 133.19ms 132.09ms

For the light query, the response time at the slower end stayed around 1.9ms as the connection count changed. Uniform arrivals of the heavy query also showed no pattern of shorter response times at the slower end with more connections.

The much larger difference came from how we sent the same 40 requests per second. With batches of 10, response time at the slower end was about 126–133ms.

With heavy queries sent 10 at a time at 40 per second, response time at the slower end is 125.98ms with 5 connections, 133.19ms with 20, and 132.09ms with 100. More connections do not produce shorter times.
Measured bursty arrivals. Bars pool all three runs; dots show each run. Both use the same measure of response time at the slower end defined in the text. Tap to enlarge. Enlarge SVG

Values calculated the same way for individual runs also varied: 125.3–126.3ms with 5 connections, 131.8–134.0ms with 20, and 130.0–136.2ms with 100 under bursty arrivals. These are the minimum and maximum across three runs, not confidence intervals. Rather than treating small differences as a general ranking, we read this as no improvement from increasing connections under these conditions.

Completed throughput was about 200 requests/sec for the light query and 40 requests/sec for either heavy-query pattern. This means the configured arrival rate was handled. We did not raise that rate to find a limit, so it does not mean every pool has a maximum capacity of 40 requests/sec.

Short bursts can hit a quota despite low average CPU usage

Linux cgroups limited all database processes together to 5ms of CPU time per 10ms period. Running multiple tasks concurrently does not increase the group's total budget. The official Linux kernel documentation explains these settings and counters.

With bursty arrivals, mean database CPU usage was 0.247 core with 5 connections, 0.260 with 20, and 0.265 with 100. That is about 49–53% of the configured 0.5-core limit.

Throttling still occurred. The ratio of throttled periods (nr_throttled) to periods recorded in cpu.stat (nr_periods) was about 84–85% with bursty arrivals, calculated from the counter increases during measurement. This is neither a share of total elapsed time nor the percentage of time spent waiting. See also the kernel documentation on statistics. A long-interval average and what happens within a short quota period describe different things.

Mean database CPU usage with bursty arrivals is 49.5%, 52.0%, and 53.0% of the 0.5-core limit for 5, 20, and 100 connections. Database cgroup memory is about 39.3, 70.8, and 238.8MiB. All connections were established in advance.
Measured bursty arrivals. CPU is shown relative to the 0.5-core limit as 100%. Memory is the average of cgroup usage at the start and end of measurement, not memory uniquely attributable to each connection.

The CPU quota is a clue when investigating time after acquisition. However, we did not run a control without that quota. Throttled-period counts cannot directly reveal an individual request's waiting time. We therefore do not attribute all the latency in this experiment to CPU throttling alone.

Memory also changed. With bursty arrivals, database cgroup usage grew from about 39MiB to 239MiB. We established every connection up to the pool limit beforehand. This does not show that merely configuring a limit of 100 in an application using few actual connections would cause the same growth.

Environment, aggregation, and resumed measurements

The environment was a Raspberry Pi 5, Linux/aarch64, 4 logical CPUs, PostgreSQL 16.13, Python 3.11.2, and asyncpg 0.31.0. Connections used a Unix socket on the same machine, which also ran other work.

Database memory was capped at 768MiB, with shared_buffers=64MiB and work_mem=1MiB. JIT and parallel queries were disabled. This read-only experiment used a small, warmed-up dataset; it cannot establish trends for writes, lock contention, disk I/O, or remote database connections.

We created each pool with min_size=0, acquired connections sequentially until all 5, 20, or 100 were established, executed SQL in advance, and returned them. Idle connection timeouts were disabled, and the actual connection count was verified before measurement.

Overall time includes the delay from a scheduled arrival until the task actually starts. Sorting these start delays and using the same method as in the body gives about 1.3–2.1ms with uniform arrivals and 6.1–7.3ms with bursts. The response time at the slower end is the ceil(count × 0.99)-th sorted value for each condition. CPU usage was the increase in usage_usec divided by observation duration, then aggregated across runs using observation duration as the weight.

Measurements span two blocks separated by a safety stop. After 11 runs, host load exceeded the safety threshold. We recreated the temporary database with the same settings and data for the remaining 16 runs. On resuming, we changed post-measurement connection shutdown to run sequentially. SQL, arrival schedules, resource caps, and warm-up during measurement were unchanged. This was not a strictly continuous experiment, which is another comparison limitation.

An earlier preliminary trial with 100 workers used a different approach after a safety stop and is excluded from this aggregation. We removed the temporary database, services, and files after the experiment and confirmed none remained.

Measurement data

The condition summaries, all 27 runs, 30,240 request measurements, and field and aggregation notes are attached. Internal monitoring records are excluded.

Conclusion: compare connection wait and overall time together

We started by asking whether increasing a pool to reduce database connection wait would also make the whole operation faster. Under these conditions, connection wait almost disappeared, but sending a query and receiving its result took longer, and the overall operation did not get faster.

If you find connection wait in your own application, you can check it in this order.

  1. Record overall response time and database-call timing before changing settings. For a web application, first record the time until the API or page responds. Around the database call, record four timestamps: immediately before requesting a connection, immediately after obtaining it, immediately after receiving the SQL result, and immediately after returning the connection. Differences between adjacent timestamps give connection wait, query round-trip time, and release time.
  2. Change only the connection count, record the same measures, and compare them. Keep the SQL, request count, and arrival intervals consistent. Check how the overall response and each interval after acquisition change, as well as connection wait.
  3. If connection wait falls but the whole operation does not get faster, investigate what follows acquisition before increasing the count again. Check the time from sending SQL to receiving results, database CPU usage and quotas, and connection release. Here, looking only at acquisition wait would have missed the query round trip growing from about 38ms to 80ms.

If more connections also shorten the overall response, that is evidence of improvement for the arrival pattern tested. Check database memory and CPU changes at the same time. Under this experiment's conditions, with every connection established in advance, more connections also meant more memory usage.

The largest batch here was 10 requests. Both 20 and 100 connections let those 10 borrow connections at the same time. These results do not cover 100 simultaneous arrivals or work with long network waits.

This experiment alone cannot tell you how many connections to configure. If you change the setting to reduce connection wait, also check whether the user's overall wait became shorter. Assess improvement after comparing both.

Publish your web app

If you want to run a web app stored on GitHub in a Japan region, KamuiDash lets you deploy it from its repository.