supplemental-docs/performance/parallel-workloads-node-js.md
Other sections such as bundle sizing, dependency count, and dynamic imports cover aspects of performance related to the initial startup of your application.
This section focuses on post-startup performance of request throughput. Specifically,
we cover performance configuration of the AWS SDK for JavaScript (v3)
in Node.js using HTTP/1.1 and the node:https module via the SDK's requestHandler
dependency, @smithy/node-http-handler.
A parallel workload is any time you make more than one request before the first request has completed.
In single-threaded JavaScript, this is accomplished via the asynchronicity of Promises.
Here is an example containing SDK Client configuration options that have an effect on request throughput.
// example: configuring an SDK client for throughput.
import { S3 } from "@aws-sdk/client-s3";
import { NodeHttpHandler } from "@aws-sdk/config/requestHandler";
import { Agent } from "node:https";
const s3 = new S3({
/**
* Default is false. Setting this to true caches
* middleware resolution and prevents modifications
* to the middlewareStack from taking effect.
*
* Use only if you are not adding custom middleware.
*/
cacheMiddleware: true,
requestHandler: new NodeHttpHandler({
httpsAgent: new Agent({
/**
* Default is true. This should be left as true
* generally speaking, unless you have very specific
* use-case needing the alternative.
*/
keepAlive: true,
/**
* See expanded note below about sockets.
* You should use a number that is the size
* of your parallel workload batch.
*/
maxSockets: 50,
}),
}),
});
// shorthand syntax available since v3.521.0
const client = new S3({
requestHandler: {
requestTimeout: 3_000,
httpsAgent: { maxSockets: 50 },
},
});
In this SDK, much functionality is cached for performance reasons, but the cache is usually associated with the client instance. In particular, the following are cached on the client instance:
cacheMiddleware=truenode:https Agent and its socket poolIf you do need multiple instances of an SDK client, but don't want to have separate credentials and socket pools, you can share credentials and requestHandlers between clients.
// example: credential and socket pool sharing from primary client.
import { S3 } from "@aws-sdk/client-s3";
const s3_east = new S3({ region: "us-east-1" });
const { credentials, requestHandler } = s3_east.config;
const s3_west = new S3({
region: "us-west-2",
credentials,
requestHandler,
});
// example: credential and socket pool sharing from user instantiated objects.
import { S3 } from "@aws-sdk/client-s3";
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
import { NodeHttpHandler } from "@aws-sdk/config/requestHandler";
const credentials = fromNodeProviderChain();
const requestHandler = new NodeHttpHandler({
httpsAgent: {
maxSockets: 100,
},
});
const s3_east = new S3({ region: "us-east-1", credentials, requestHandler });
const s3_west = new S3({ region: "us-west-2", credentials, requestHandler });
The node:https Agent class manages sockets on your behalf. The most impactful configuration you can make for parallel workloads is to set
the value of maxSockets.
Configuring the maxSockets value for the SDK's requestHandler should
be based on the parallelism or parallel workload batch size of your application
and usage scenario.
Error: EMFILE, too many open files
in Node.js.Because streaming responses stay open until you completely read or abandon the open stream connection, the socket limit which you configure and how you handle streams can lead to a deadlock scenario.
In this example, the max socket count is 1. When the Promise concurrency tries to await
two simultaneous getObject requests, it fails because the streaming response of the first request
is not read to completion, while the second getObject request is blocked indefinitely.
This can cause your application to time out or the Node.js process to exit with code 13.
// example: response stream deadlock
const s3 = new S3({
requestHandler: {
httpsAgent: {
maxSockets: 1,
},
},
});
// opens connection in parallel,
// but this await is dangerously placed.
const responses = await Promise.all([s3.getObject({ Bucket, Key: "1" }), s3.getObject({ Bucket, Key: "2" })]);
// reads streaming body in parallel
await responses.map((get) => get.Body.transformToByteArray());
This doesn't necessarily mean you need to de-parallelize your program. You can continue to use a socket count lower than your maxmimum parallel load, as long as the requests do not block each other.
In this example, reading of the body streams is done in a non-blocking way.
// example: parallel streaming without deadlock
const s3 = new S3({
requestHandler: {
httpsAgent: {
maxSockets: 1,
},
},
});
const responses = [s3.getObject({ Bucket, Key: "1" }), s3.getObject({ Bucket, Key: "2" })];
const objects = responses.map((get) => get.Body.transformToByteArray());
// `await` is placed after the stream-handling pipeline has been established.
// because of the socket limit of 1, the two streams will be handled
// sequentially via queueing.
await Promise.all(objects);
You have 10,000 files to upload to S3.
Test your application to determine the right level of parallel request traffic.
After that, configure the maxSockets value to be equal to the batch size, or
a factor of it.
// example: workload of 10,000 files, batch size of 100.
import { S3 } from "@aws-sdk/client-s3";
const files = [
/*... */
];
const BATCH_SIZE = 100;
const s3 = new S3({
requestHandler: {
httpsAgent: { maxSockets: 100 },
},
});
const promises = [];
while (files.length) {
promises.push(
...files.slice(0, BATCH_SIZE).map((file) => {
return s3.putObject({
Bucket: "...",
Key: file.name,
Body: file.contents,
});
})
);
await Promise.all(promises);
promises.length = 0;
}
In this example we've adhered to the best practices mentioned in this section:
maxSockets value that is a factor of the batch size