Guide · Lambda + SQS

MCP Server Lambda SQS Event Source — batch window, partial failures, concurrency control

A Lambda event source mapping (ESM) wired to an SQS queue eliminates the polling loop — Lambda manages the queue drain automatically, scales to match queue depth, and handles visibility timeout extension during function execution. For MCP tool workers, the ESM is the lowest-operational-overhead way to process queued tool calls: no polling infrastructure to maintain, automatic concurrency scaling, and built-in integration with the SQS DLQ via maxReceiveCount. The four configuration parameters that determine whether the ESM processes tool calls correctly are batchSize (how many SQS messages per Lambda invocation), MaximumBatchingWindowInSeconds (how long Lambda waits to fill a batch before invoking), FunctionResponseTypes: ['ReportBatchItemFailures'] (enables partial batch failure reporting so one failed message doesn't retry the entire batch), and reserved concurrency (prevents the ESM from consuming all available Lambda concurrency during queue bursts, starving other functions). Missing any of these produces either redundant tool executions, unnecessary retries, or Lambda concurrency exhaustion.

TL;DR

Set batchSize to 1 for expensive/slow tool calls (each invocation = one tool call, maximum isolation), or up to 10 for cheap tool calls (higher throughput per invocation). Set MaximumBatchingWindowInSeconds to 0 for latency-sensitive tool calls. Always set FunctionResponseTypes: ['ReportBatchItemFailures'] and return batchItemFailures from your handler — without this, a single failed message retries the entire batch. Set ScalingConfig.MaximumConcurrency on the ESM (1–1,000) to cap how many concurrent Lambda invocations the ESM creates. Lambda automatically extends visibility timeout during function execution — do not manually call ChangeMessageVisibility from inside an ESM-invoked Lambda.

How the ESM pulls and scales

When you create an SQS event source mapping on a Lambda function, Lambda runs a fleet of internal pollers that call ReceiveMessage on your SQS queue continuously. Lambda scales the number of pollers based on queue depth: it starts with 5 pollers and scales up to 1,000 pollers (for standard queues) — adding roughly 60 pollers per minute when the queue depth is increasing. Each poller invokes one Lambda function with a batch of messages.

For MCP tool workers, this means: during a burst of tool calls (100 calls enqueued simultaneously), Lambda scales from 5 to ~100 concurrent invocations within 2–3 minutes. During quiet periods, it scales back to a small number of pollers consuming messages as they arrive.

The auto-scaling happens within the function's concurrency limit. If the function has no reserved concurrency, the ESM competes with all other functions in the account for the regional unreserved concurrency pool. During a burst, a tool worker ESM could consume all available concurrency and throttle other functions. Set ScalingConfig.MaximumConcurrency on the ESM to cap this.

import {
  LambdaClient,
  CreateEventSourceMappingCommand,
  UpdateEventSourceMappingCommand,
  type CreateEventSourceMappingCommandInput,
} from '@aws-sdk/client-lambda';

const lambda = new LambdaClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

const TOOL_QUEUE_ARN = process.env.TOOL_QUEUE_ARN!;
const TOOL_FUNCTION_NAME = process.env.TOOL_FUNCTION_NAME!;

// Create the event source mapping
const input: CreateEventSourceMappingCommandInput = {
  FunctionName: TOOL_FUNCTION_NAME,
  EventSourceArn: TOOL_QUEUE_ARN,
  BatchSize: 1,                    // 1 tool call per Lambda invocation — maximum isolation
  MaximumBatchingWindowInSeconds: 0, // no wait — invoke as soon as message arrives
  FunctionResponseTypes: ['ReportBatchItemFailures'], // REQUIRED for partial failure support
  ScalingConfig: {
    MaximumConcurrency: 100,       // cap at 100 concurrent tool workers
  },
  // For DLQ: set maxReceiveCount on the SQS queue's redrive policy (not here)
  // Lambda respects the SQS DLQ — after maxReceiveCount failures, SQS moves message to DLQ
};

await lambda.send(new CreateEventSourceMappingCommand(input));

Lambda manages the visibility timeout during function execution automatically. When Lambda receives a batch of messages, it extends the visibility timeout of those messages every minute during function execution (up to the queue's maximum visibility timeout). You do NOT need to call ChangeMessageVisibility from inside the function — Lambda does this for you. The one exception: if your function's timeout exceeds the queue's visibility timeout, messages become visible again before the function finishes. Always set the queue's visibility timeout to at least 6× the function timeout to give Lambda room to extend.

batchSize and MaximumBatchingWindowInSeconds

batchSize controls how many SQS messages are included in a single Lambda invocation. The maximum is 10,000 for standard queues and 10 for FIFO queues. Higher batch sizes increase throughput efficiency (less Lambda invocation overhead per message) but introduce all-or-nothing retry risk: if the batch contains one bad message, the entire batch is retried unless you implement partial failure reporting.

MaximumBatchingWindowInSeconds (0–300 seconds) is how long Lambda waits to accumulate messages up to batchSize before invoking the function. With MaximumBatchingWindowInSeconds: 0, Lambda invokes the function as soon as a message is received, even if batchSize is 10 (the batch may contain fewer than 10 messages). With MaximumBatchingWindowInSeconds: 5, Lambda waits up to 5 seconds before invoking, giving the queue time to accumulate more messages.

For MCP tool calls where latency matters (the MCP client is waiting for a result), set MaximumBatchingWindowInSeconds: 0. For offline processing or batch analytics where throughput matters more than latency, set it to 30–300 seconds.

// Configuration matrix for MCP tool workers

// Latency-sensitive, expensive tool calls (web search, code execution, LLM calls)
const highLatencyConfig: Partial = {
  BatchSize: 1,
  MaximumBatchingWindowInSeconds: 0,
  FunctionResponseTypes: ['ReportBatchItemFailures'],
  ScalingConfig: { MaximumConcurrency: 50 },
};

// High-throughput, cheap tool calls (cache lookups, metadata reads)
const highThroughputConfig: Partial = {
  BatchSize: 10,
  MaximumBatchingWindowInSeconds: 2,
  FunctionResponseTypes: ['ReportBatchItemFailures'],
  ScalingConfig: { MaximumConcurrency: 200 },
};

// FIFO queue (different limits)
const fifoConfig: Partial = {
  BatchSize: 10,                    // max 10 for FIFO queues
  MaximumBatchingWindowInSeconds: 0,
  FunctionResponseTypes: ['ReportBatchItemFailures'],
  // ScalingConfig not applicable for FIFO — Lambda processes one group at a time
};

Partial failure reporting with batchItemFailures

Without FunctionResponseTypes: ['ReportBatchItemFailures'], a Lambda SQS ESM has two outcomes: the function returns successfully (all messages in the batch are deleted from the queue) or the function throws an unhandled exception (all messages in the batch are returned to the queue and retried). A single bad message that causes the function to throw forces every good message in the batch to be reprocessed.

With ReportBatchItemFailures enabled, the Lambda function can return a structured response indicating which specific message IDs failed. SQS deletes the messages that succeeded and re-queues only the messages that failed. This is critical for MCP tool workers where batches contain independent tool calls from different sessions — a failure in one tool call should not force reprocessing of unrelated tool calls.

interface SQSRecord {
  messageId: string;
  receiptHandle: string;
  body: string;
  attributes: {
    ApproximateReceiveCount: string;
    SentTimestamp: string;
    MessageGroupId?: string;
    SequenceNumber?: string;
  };
  messageAttributes: Record;
}

interface SQSEvent {
  Records: SQSRecord[];
}

interface BatchItemFailure {
  itemIdentifier: string;
}

interface SQSBatchResponse {
  batchItemFailures: BatchItemFailure[];
}

// MCP tool worker Lambda handler with partial failure reporting
export async function handler(event: SQSEvent): Promise {
  const failures: BatchItemFailure[] = [];

  // Process messages concurrently (for independent tool calls with batchSize > 1)
  await Promise.allSettled(
    event.Records.map(async (record) => {
      try {
        const payload = JSON.parse(record.body);
        await executeToolCall(payload);
      } catch (err) {
        console.error({
          event: 'tool_call_failed',
          messageId: record.messageId,
          receiveCount: parseInt(record.attributes.ApproximateReceiveCount, 10),
          error: String(err),
          body: record.body,
        });
        failures.push({ itemIdentifier: record.messageId });
      }
    })
  );

  return { batchItemFailures: failures };
}

// For FIFO queues: process sequentially within the batch, stop on first failure
// (all subsequent messages in the group must wait for the failed one)
export async function fifoHandler(event: SQSEvent): Promise {
  const failures: BatchItemFailure[] = [];

  for (const record of event.Records) {
    // If any previous message in this group failed, skip subsequent ones
    if (failures.length > 0) {
      failures.push({ itemIdentifier: record.messageId });
      continue;
    }

    try {
      const payload = JSON.parse(record.body);
      await executeToolCall(payload);
    } catch (err) {
      console.error({ messageId: record.messageId, error: String(err) });
      failures.push({ itemIdentifier: record.messageId });
      // For FIFO: returning here without processing subsequent messages
      // preserves ordering — subsequent messages will be delivered again
      // in correct order after this one is resolved
    }
  }

  return { batchItemFailures: failures };
}

Concurrency control with ScalingConfig

ScalingConfig.MaximumConcurrency (available for standard SQS ESMs, not FIFO) caps the number of concurrent Lambda invocations the ESM creates. Valid range: 2–1,000. Without it, the ESM scales to the function's unreserved concurrency limit, which can be thousands of concurrent invocations during a queue burst.

For MCP tool workers that call expensive downstream APIs (LLM APIs, database connections), uncapped concurrency creates two problems: downstream API throttling (1,000 simultaneous calls to an LLM API will all get 429s), and database connection pool exhaustion (1,000 concurrent Lambda functions opening 1,000 database connections). Set MaximumConcurrency to match your downstream API's sustainable request rate divided by average requests per tool call.

Note: ScalingConfig.MaximumConcurrency is set on the ESM (event source mapping), not on the function. It caps the concurrency from this specific ESM. If the function also has reserved concurrency set, the effective cap is the lower of the two values.

// Update MaximumConcurrency on an existing ESM
const { EventSourceMappings } = await lambda.send(new ListEventSourceMappingsCommand({
  FunctionName: TOOL_FUNCTION_NAME,
  EventSourceArn: TOOL_QUEUE_ARN,
}));

const esmUUID = EventSourceMappings?.[0]?.UUID;
if (esmUUID) {
  await lambda.send(new UpdateEventSourceMappingCommand({
    UUID: esmUUID,
    ScalingConfig: {
      MaximumConcurrency: 50, // reduce from 100 to 50 if downstream throttling observed
    },
  }));
}

// Reserved concurrency on the function itself (separate from ESM MaximumConcurrency)
// Use reserved concurrency to guarantee minimum capacity AND cap maximum concurrency
import { PutFunctionConcurrencyCommand } from '@aws-sdk/client-lambda';

await lambda.send(new PutFunctionConcurrencyCommand({
  FunctionName: TOOL_FUNCTION_NAME,
  ReservedConcurrentExecutions: 50,
  // This prevents ANY invocation (ESM or direct) from exceeding 50 concurrent executions
  // Combines with ScalingConfig.MaximumConcurrency: effective cap = min(ESM max, reserved)
}));

FIFO queue behavior with Lambda ESM

When a Lambda function consumes a FIFO queue via an ESM, Lambda processes one message group at a time: it delivers a batch of up to batchSize messages from a single MessageGroupId, waits for the function to complete, then moves to the next group. This preserves FIFO ordering within a group but limits parallelism — two groups can be processed simultaneously only if two separate Lambda invocations are running.

In practice, Lambda scales FIFO queue processing by processing different message groups in parallel. If your queue has 10 active session groups, Lambda can run 10 concurrent invocations, one per group. However, within a group, messages are always processed sequentially (batch-by-batch, not message-by-message).

A failure in a FIFO batch blocks the entire group: if the Lambda function returns a batchItemFailure for message 3 in a group, SQS does not deliver message 4, 5, or 6 from that group until message 3 is either successfully processed or moved to the DLQ. This is correct behavior for ordered tool call sequences — you don't want to execute step 5 if step 3 failed — but it means a broken tool call blocks the entire session's queue drain until the failure is resolved.

// Monitor FIFO ESM processing: check for stalled message groups
import { GetQueueAttributesCommand } from '@aws-sdk/client-sqs';

async function checkFifoQueueHealth(queueUrl: string): Promise {
  const { Attributes } = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: queueUrl,
    AttributeNames: [
      'ApproximateNumberOfMessages',
      'ApproximateNumberOfMessagesNotVisible',
      'NumberOfMessageGroups', // FIFO only
    ],
  }));

  const visible = parseInt(Attributes?.ApproximateNumberOfMessages ?? '0', 10);
  const inFlight = parseInt(Attributes?.ApproximateNumberOfMessagesNotVisible ?? '0', 10);

  // High ratio of in-flight to visible suggests a blocked group
  // (Lambda is stuck retrying a failed message, holding other group messages invisible)
  if (inFlight > 0 && visible > 0) {
    const ratio = inFlight / (visible + inFlight);
    if (ratio > 0.5) {
      console.warn({
        event: 'fifo_group_possibly_stalled',
        visible,
        inFlight,
        inFlightRatio: ratio,
      });
    }
  }
}

Failure modes reference

FailureSymptomFix
FunctionResponseTypes not set to ReportBatchItemFailuresSingle failed message causes entire batch to retry; good tool calls re-execute unnecessarilySet FunctionResponseTypes: ['ReportBatchItemFailures'] on the ESM; return batchItemFailures from handler
No ScalingConfig.MaximumConcurrency setQueue burst triggers thousands of concurrent Lambdas; downstream API throttled; DB connection pool exhaustedSet MaximumConcurrency to match downstream API sustainable rate
Queue VisibilityTimeout shorter than Lambda function timeoutLambda still processing when VT expires; SQS re-delivers to another Lambda; duplicate tool executionSet queue VisibilityTimeout to at least 6× Lambda function timeout
Manually calling ChangeMessageVisibility from ESM LambdaUnnecessary API calls; conflicts with Lambda's own VT extension; potential race conditionLet Lambda manage VT extension automatically; never call ChangeMessageVisibility from ESM-triggered handlers
FIFO queue: not stopping on first batch failureLater messages in group processed before earlier failed message resolved; ordering violationFor FIFO handlers: on first failure, add all subsequent messageIds to batchItemFailures; return immediately
Lambda concurrency limit hit during burstESM throttled; SQS messages accumulate; queue depth grows faster than Lambda drainsSet reserved concurrency on the function; set ScalingConfig.MaximumConcurrency on the ESM; ensure they match
batchSize > 1 without ReportBatchItemFailures and processing in parallelOne tool call failure causes all tool calls in batch to be retried; idempotency check required for all or double execution occursAlways pair batchSize > 1 with ReportBatchItemFailures; or use batchSize: 1 for simplest correctness