Skip to main content
Every SDK client exposes a middlewareStack property. You can add custom behavior to any step of the request lifecycle — logging, header injection, retry hooks, and more — without modifying client internals.
import { S3Client } from "@aws-sdk/client-s3";

const client = new S3Client({ region: "us-west-2" });

client.middlewareStack.add(
  (next, context) => async (args) => {
    // runs before the request
    const result = await next(args);
    // runs after the response
    return result;
  },
  { step: "build", name: "MyMiddleware" }
);

Middleware function anatomy

A middleware is a higher-order function with this signature:
type Middleware<Input, Output> = (
  next: Handler<Input, Output>,
  context: HandlerExecutionContext
) => Handler<Input, Output>;

type Handler<Input, Output> = (args: {
  input: Input;
  request?: HttpRequest; // available after serialize step
}) => Promise<{ output: Output; response: unknown }>;
In practice:
const myMiddleware = (next, context) => async (args) => {
  // --- before the downstream handler ---
  console.log("command:", context.commandName);
  console.log("client:", context.clientName);
  // args.input is always available
  // args.request is available in build, finalizeRequest, and deserialize steps

  const result = await next(args);

  // --- after the downstream handler ---
  // result.output is the deserialized response object
  // result.response is the raw HTTP response
  return result;
};
context properties:
PropertyTypeDescription
clientNamestringe.g., "S3Client"
commandNamestringe.g., "GetObjectCommand"
loggerLoggerThe client’s logger instance
inputFilterSensitiveLogFunctionRedacts sensitive fields from input
outputFilterSensitiveLogFunctionRedacts sensitive fields from output

The five lifecycle steps

The API call is being set up. Default option values are injected and derived parameters computed. No HTTP request exists yet.Available in args: input only.Typical uses: injecting default input values, validating logical constraints before serialization.
client.middlewareStack.add(
  (next) => async (args) => {
    // Ensure a default tag is always present
    args.input = {
      ...args.input,
      TagSet: args.input.TagSet ?? [{ Key: "env", Value: "production" }],
    };
    return next(args);
  },
  { step: "initialize", name: "defaultTagsMiddleware" }
);
The input is serialized into an HTTP request. Input validation runs here. The downstream middleware receives args.request.Available in args: input, request (newly created).Typical uses: inspecting or validating the serialized request before it is modified.
client.middlewareStack.add(
  (next) => async (args) => {
    // args.request is now an HttpRequest
    console.log("Endpoint:", (args.request as any).hostname);
    return next(args);
  },
  { step: "serialize", name: "logEndpointMiddleware" }
);
The HTTP request exists and can be modified. Any changes here apply to all retries. This is the most common step for injecting custom headers.Available in args: input, request.Typical uses: adding custom headers (Content-Length, checksums, correlation IDs), logging the raw request.
client.middlewareStack.add(
  (next, context) => async (args) => {
    // Inject a custom header on every request
    (args.request as any).headers["x-request-id"] = crypto.randomUUID();
    (args.request as any).headers["x-client-name"] = context.clientName;

    console.log("Request headers:", (args.request as any).headers);
    const result = await next(args);
    console.log("Response status:", (result.response as any).statusCode);
    return result;
  },
  { step: "build", name: "requestTracingMiddleware", tags: ["TRACING"] }
);
The request is semantically complete. This is where request signing and retry logic run. Only alter the request to match recipient expectations (hop-by-hop headers, auth signatures).Available in args: input, request (signed on downstream pass).Typical uses: retry hooks, custom auth headers, conditional request modification.
client.middlewareStack.add(
  (next) => async (args) => {
    let attempt = 0;
    while (true) {
      try {
        return await next(args);
      } catch (err: any) {
        if (attempt++ >= 2 || err.name !== "ThrottlingException") throw err;
        await new Promise((r) => setTimeout(r, 200 * attempt));
      }
    }
  },
  { step: "finalizeRequest", name: "customRetryMiddleware", priority: "low" }
);
The raw HTTP response is deserialized into a structured JavaScript object. Upstream middleware (running after await next(args)) see the final result.output.Available in result: output (structured response), response (raw HTTP).Typical uses: inspecting response metadata, transforming output, recording metrics.
client.middlewareStack.add(
  (next) => async (args) => {
    const result = await next(args);
    // result.output is the full deserialized response
    const metadata = (result.output as any).$metadata;
    console.log("HTTP status:", metadata.httpStatusCode);
    console.log("Request ID:", metadata.requestId);
    return result;
  },
  { step: "deserialize", name: "responseMetadataMiddleware" }
);

middlewareStack.add(middleware, options)

Adds middleware at an absolute position in a step.
client.middlewareStack.add(middleware, {
  step: "build",
  name: "MyMiddleware",
  priority: "normal",
  tags: ["LOGGING"],
  override: true,
});
options.step
string
required
Which lifecycle step to run in. One of: "initialize", "serialize", "build", "finalizeRequest", "deserialize".
options.name
string
Unique string identifier. Required when using override: true or remove().
options.priority
string
default:"normal"
Execution order within the step. "high" runs first, "low" runs last. When two middleware share the same priority, they run in insertion order.
options.tags
string[]
Array of string tags. Used with removeByTag() to remove groups of middleware.
options.override
boolean
default:"false"
When true, replaces any existing middleware with the same name instead of throwing. Provide both name and override: true to avoid accidental duplication.

middlewareStack.addRelativeTo(middleware, options)

Adds middleware relative to another named middleware, regardless of step.
client.middlewareStack.add(baseMiddleware, {
  step: "build",
  name: "baseMiddleware",
});

client.middlewareStack.addRelativeTo(myMiddleware, {
  relation: "before", // or "after"
  toMiddleware: "baseMiddleware",
  name: "myMiddleware",
  tags: ["CUSTOM"],
});
options.relation
string
required
"before" to run before the target middleware, "after" to run after.
options.toMiddleware
string
required
The name of the middleware to position relative to.
options.name
string
Identifier for the new middleware.
options.tags
string[]
Tags for the new middleware.
options.override
boolean
default:"false"
Replaces an existing middleware with the same name.

middlewareStack.remove(nameOrTag)

Removes a single middleware by name. Returns true if a middleware was removed, false otherwise.
client.middlewareStack.remove("MyMiddleware");

middlewareStack.removeByTag(tag)

Removes all middleware with the given tag. Returns true if any middleware were removed.
// Add multiple middleware with a shared tag
client.middlewareStack.add(authMiddleware, { step: "finalizeRequest", tags: ["AUTH"] });
client.middlewareStack.add(tokenRefreshMiddleware, { step: "initialize", tags: ["AUTH"] });

// Remove them all at once
client.middlewareStack.removeByTag("AUTH");

middlewareStack.clone()

Returns a deep copy of the stack. Useful when you need to create a modified stack for a single command without affecting the client’s global stack.
const stackCopy = client.middlewareStack.clone();
stackCopy.add(oneOffMiddleware, { step: "build" });

middlewareStack.concat(stack)

Merges another MiddlewareStack into this one and returns the result. The original stacks are not mutated.
import { MiddlewareStack } from "@smithy/types";

const sharedStack = new MiddlewareStack();
sharedStack.add(loggingMiddleware, { step: "build", name: "loggingMiddleware" });

const mergedStack = client.middlewareStack.concat(sharedStack);

middlewareStack.use(plugin)

Applies a plugin to the stack. A plugin is any object with an applyToStack(stack) method, which is called with the middleware stack as its argument.
const myPlugin = {
  applyToStack(stack) {
    stack.add(loggingMiddleware, { step: "build", name: "logger" });
    stack.add(tracingMiddleware, { step: "initialize", name: "tracer" });
  },
};

client.middlewareStack.use(myPlugin);

Per-command middleware

Middleware can be added to an individual command instance instead of the client. It is merged with the client stack only for that invocation.
import { GetObjectCommand } from "@aws-sdk/client-s3";

const command = new GetObjectCommand({ Bucket: "my-bucket", Key: "my-key" });

command.middlewareStack.add(
  (next) => async (args) => {
    console.log("This only runs for this GetObjectCommand call");
    return next(args);
  },
  { step: "build", name: "commandSpecificMiddleware" }
);

await client.send(command);

Examples

Logging all requests and responses

import { DynamoDB } from "@aws-sdk/client-dynamodb";

const client = new DynamoDB({ region: "us-west-2" });

client.middlewareStack.add(
  (next, context) => async (args) => {
    console.log(`[${context.commandName}] input:`, args.input);
    const result = await next(args);
    console.log(`[${context.commandName}] output:`, result.output);
    return result;
  },
  { step: "initialize", name: "requestLogger" }
);

Injecting a custom header on every request

import { S3 } from "@aws-sdk/client-s3";

const client = new S3({ region: "us-west-2" });

client.middlewareStack.add(
  (next) => async (args) => {
    (args.request as any).headers["x-amz-meta-foo"] = "bar";
    return next(args);
  },
  {
    step: "build",
    name: "addFooMetadataMiddleware",
    tags: ["METADATA", "FOO"],
    override: true,
  }
);

await client.putObject({ Bucket: "my-bucket", Key: "my-key", Body: "hello" });

Measuring operation latency

client.middlewareStack.add(
  (next, context) => async (args) => {
    const start = Date.now();
    try {
      const result = await next(args);
      console.log(`${context.commandName} succeeded in ${Date.now() - start}ms`);
      return result;
    } catch (err) {
      console.log(`${context.commandName} failed in ${Date.now() - start}ms`);
      throw err;
    }
  },
  { step: "initialize", name: "latencyMiddleware" }
);

Custom header that must be present on retries

// Add in the "build" step so the header is included on every retry attempt
client.middlewareStack.add(
  (next) => async (args) => {
    (args.request as any).headers["x-idempotency-key"] = generateIdempotencyKey(args.input);
    return next(args);
  },
  { step: "build", name: "idempotencyMiddleware" }
);
Use the build step for headers that must survive retries. Headers added in finalizeRequest may be regenerated per retry by the signing middleware.

Middleware stack and cacheMiddleware

By default the resolved middleware function stack is rebuilt on every request, allowing you to modify the stack at any time. If you enable cacheMiddleware: true on the client, the stack is compiled once per (client, command class) pair and reused. This reduces per-request overhead by a few milliseconds but means that stack modifications after the first request have no effect.
const client = new S3Client({ cacheMiddleware: true });
Only enable this if you need the performance benefit and are certain your stack will not change after the first request.

Build docs developers (and LLMs) love