A service client is more than a convenience wrapper. It owns a pool of HTTP connections, and that pool is the single biggest lever over per-call latency in SDK-heavy code. Building a new client for every request throws that pool away and pays to rebuild it, over and over.
This page covers reusing clients correctly, sizing the pool, and the specific Lambda pattern that trips people up most often.
Create the client once, at module or application scope, and pass it to whatever code needs to make calls. Never construct a client inside a request handler, a loop body, or a per-item function.
# --- Python (boto3) ---import boto3# Module scope: created once when the module loadss3 = boto3.client("s3", region_name="us-east-1")def process(key: str): return s3.head_object(Bucket="my-bucket", Key=key)
// --- TypeScript (AWS SDK v3) ---import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";// Module scope: created once when the module loadsconst s3 = new S3Client({ region: "us-east-1" });export function process(key: string) { return s3.send(new HeadObjectCommand({ Bucket: "my-bucket", Key: key }));}
Every subsequent call on s3 reuses whatever connections are already open in its pool, skipping the TCP and TLS handshake that a fresh client would have to pay again.
The pattern matters most in Lambda, where the classic anti-pattern is constructing the client inside the handler.
# --- Python (boto3) ---import boto3from botocore.config import Config# Module scope: survives across warm invocations of this functioncfg = Config(max_pool_connections=20)ddb = boto3.client("dynamodb", config=cfg)def handler(event, context): # Anti-pattern would be: ddb = boto3.client("dynamodb") right here return ddb.get_item(TableName="Sessions", Key={"id": {"S": event["id"]}})
// --- TypeScript (AWS SDK v3) ---import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";import { NodeHttpHandler } from "@smithy/node-http-handler";import { Agent } from "node:https";// Module scope: survives across warm invocations of this functionconst ddb = new DynamoDBClient({ requestHandler: new NodeHttpHandler({ httpsAgent: new Agent({ maxSockets: 20 }) }),});export const handler = async (event: { id: string }) => { // Anti-pattern would be: const ddb = new DynamoDBClient({}) right here return ddb.send(new GetItemCommand({ TableName: "Sessions", Key: { id: { S: event.id } } }));};
On a cold start, both handlers pay the same client-construction and first-connection cost regardless of where the client lives. The difference shows up on every warm invocation after that: with the client at module scope, the execution environment reuses the same warm client and pool across warm invocations. With the client inside the handler, every single invocation, warm or cold, gets a brand-new client and a cold pool.
A client object bundles configuration (region, credentials, retry policy) with an HTTP layer that holds open connections. Reusing the client reuses that HTTP layer, so a call that lands on an already-open connection skips the handshake entirely. This is the same underlying pool discussed in the sdk-mechanics section's timeouts and connection configuration page; this page is about making sure code actually gets to reuse it, rather than defeating it by construction.
The pool has a maximum size, and once concurrent in-flight calls exceed it, extra calls wait for a connection to free up rather than opening a new one. That wait is invisible in application logs - it just looks like the call took longer than expected.
# --- Python (boto3) ---from botocore.config import Configcfg = Config(max_pool_connections=50) # size to expected concurrent calls on this clients3 = boto3.client("s3", config=cfg)
// --- TypeScript (AWS SDK v3) ---import { S3Client } from "@aws-sdk/client-s3";import { NodeHttpHandler } from "@smithy/node-http-handler";import { Agent } from "node:https";const s3 = new S3Client({ requestHandler: new NodeHttpHandler({ httpsAgent: new Agent({ maxSockets: 50 }), // size to expected concurrent calls on this client }),});
Size the pool to the concurrency you actually expect against that client, not to some arbitrary large number. An oversized pool wastes file descriptors and memory; an undersized one silently queues. Pair this with the concurrency limits discussed later in this section so the two numbers agree with each other.
A single boto3 client can be shared across threads; a single SDK v3 client can be shared across concurrent async/await calls. There's no need to construct one client per thread or one per concurrent task - that would only recreate the per-call-construction problem under a different name. One client per logical service dependency, shared across all callers of that dependency, is the right granularity.
The module-scope-versus-handler-scope distinction is the single highest-leverage fix in serverless code. It costs nothing to write correctly and nothing to verify: move the boto3.client(...) or new XxxClient(...) call above the handler function definition, so it runs once when the module is imported rather than once per invocation.
Constructing the client inside the handler. This is the classic Lambda anti-pattern; it pays connection setup on every invocation instead of only the first warm one.
Constructing a new client per loop iteration. Same problem, different shape - each iteration gets a cold pool.
An undersized pool that silently queues. No error is raised; it just shows up as added latency under concurrency.
Assuming reuse is unsafe across threads or async tasks. Both SDKs are designed for shared use; don't create one client per worker out of unfounded caution.
Forgetting the pool size and concurrency limit are related. A pool of 10 with a concurrency limit of 50 just moves the bottleneck from the service to the local pool.
A connection-pooling proxy or sidecar in front of the AWS endpoint is occasionally used in very high-throughput systems, but it adds an operational component most workloads don't need when the SDK's own pool is sized correctly.
Per-region or per-account client instances are a legitimate reason to have more than one client, but each of those should still be constructed once and reused, not rebuilt per call.
HTTP/2 or connection multiplexing at the transport layer is handled by the SDK's HTTP stack already; there's no separate knob to reach for here.
At module or application scope, outside any request handler or loop, so it is created once and its connection pool is reused for every call.
Why does client construction matter more in Lambda?
Because a client constructed inside the handler is rebuilt on every invocation, warm or cold, discarding a pool that could otherwise have survived across warm invocations.
Is it safe to share one client across threads or concurrent async calls?
Yes. boto3 clients are thread-safe and AWS SDK v3 clients are safe to use concurrently from multiple async calls; there's no need for one client per worker.
How do I size the connection pool?
Set max_pool_connections (boto3) or an Agent({ maxSockets }) passed to NodeHttpHandler (SDK v3) to roughly the number of concurrent in-flight calls you expect against that client.
What happens if the pool is too small?
Calls beyond the pool size queue for a free connection instead of erroring, which shows up as unexplained added latency rather than a visible failure.
Does creating one client per AWS service make sense?
Yes - one client per service (S3, DynamoDB, SQS, and so on) is normal, since each targets a different endpoint and API. The rule is one reused client per service dependency, not one per call.