Guide · AWS SQS

SQS Batch Operations for MCP Servers — SendMessageBatch, Lambda ESM batchSize, Partial Failures

SQS batch operations allow you to send, receive, and delete up to 10 messages in a single API call, reducing both cost and latency compared to one-at-a-time operations. For MCP monitoring pipelines where a probe cycle generates dozens of results — one per monitored endpoint — batching is the primary mechanism to keep SQS costs linear with message volume rather than proportional to API call count. The three operations are SendMessageBatch (send up to 10, max 256 KB total body), ReceiveMessage with MaxNumberOfMessages (receive up to 10 per call), and DeleteMessageBatch (delete up to 10 by receipt handle). Lambda's Event Source Mapping adds two additional levers: batchSize (how many messages Lambda receives per invocation, up to 10,000 for standard queues) and maximumBatchingWindowInSeconds (how long Lambda waits to accumulate a full batch before invoking). The critical operational detail: partial batch failure — when one message in a batch fails processing, what happens to the rest.

TL;DR

SendMessageBatch: up to 10 messages, 256 KB total. ReceiveMessage: up to 10 per call (set MaxNumberOfMessages: 10 always). DeleteMessageBatch: up to 10 receipt handles per call — always batch your deletes. Lambda ESM batchSize: 1–10 for FIFO queues, 1–10,000 for standard queues. Enable reportBatchItemFailures in the Lambda response to retry only failed items in a batch instead of retrying the entire batch — without it, any single failure retries all 10 messages.

SendMessageBatch: cost and throughput

SQS charges per 64 KB chunk of payload per API call. A single SendMessage with a 1 KB body counts as one request. A SendMessageBatch with 10 × 1 KB bodies also counts as one request. Sending 10 probe results individually costs 10× what sending them as a batch costs.

Batch limits:

import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';

const sqs = new SQSClient({});

// Send a batch of probe results
async function sendProbeResultBatch(
  queueUrl: string,
  results: ProbeResult[]
): Promise {
  if (results.length === 0) return;

  // SQS batch max is 10
  for (let i = 0; i < results.length; i += 10) {
    const chunk = results.slice(i, i + 10);

    const resp = await sqs.send(new SendMessageBatchCommand({
      QueueUrl: queueUrl,
      Entries: chunk.map((result, idx) => ({
        Id: `msg-${idx}`,                                          // unique within batch
        MessageBody: JSON.stringify(result),
        MessageGroupId: result.endpointId,                        // FIFO: per-endpoint ordering
        MessageDeduplicationId: `${result.endpointId}-${result.probeRunId}`,
      })),
    }));

    if (resp.Failed && resp.Failed.length > 0) {
      for (const failure of resp.Failed) {
        console.error('SendMessageBatch partial failure', {
          id: failure.Id,
          code: failure.Code,
          message: failure.Message,
          senderFault: failure.SenderFault,
        });
        // SenderFault=true: your message is malformed (not retriable as-is)
        // SenderFault=false: service-side error (retriable)
      }
    }
  }
}

ReceiveMessage and DeleteMessageBatch

Always set MaxNumberOfMessages: 10 on ReceiveMessage calls. The default is 1 — using the default means each call returns at most one message, regardless of how many are available, which is ten times less efficient than it could be.

After processing, batch your deletes. SQS charges one API call per DeleteMessage and one call per DeleteMessageBatch regardless of how many handles are in the batch. Deleting 10 messages individually costs 10×.

import { SQSClient, ReceiveMessageCommand, DeleteMessageBatchCommand } from '@aws-sdk/client-sqs';

const sqs = new SQSClient({});

async function drainQueue(queueUrl: string): Promise {
  const resp = await sqs.send(new ReceiveMessageCommand({
    QueueUrl: queueUrl,
    MaxNumberOfMessages: 10,         // always 10 — leave nothing on the table
    WaitTimeSeconds: 20,             // long polling
    AttributeNames: ['All'],
    MessageAttributeNames: ['All'],
  }));

  if (!resp.Messages || resp.Messages.length === 0) return;

  // Process all messages
  const deleteEntries: { Id: string; ReceiptHandle: string }[] = [];

  for (const msg of resp.Messages) {
    try {
      await processProbeResult(JSON.parse(msg.Body!));
      deleteEntries.push({ Id: msg.MessageId!, ReceiptHandle: msg.ReceiptHandle! });
    } catch (err) {
      console.error('Failed to process message', msg.MessageId, err);
      // Do not add to deleteEntries — message will reappear after visibility timeout
    }
  }

  if (deleteEntries.length > 0) {
    await sqs.send(new DeleteMessageBatchCommand({
      QueueUrl: queueUrl,
      Entries: deleteEntries,
    }));
  }
}

Lambda ESM: batchSize and maximumBatchingWindowInSeconds

Lambda's Event Source Mapping has two batch controls that interact:

ParameterStandard SQSFIFO SQSEffect
batchSize1–10,0001–10Maximum messages per Lambda invocation
maximumBatchingWindowInSeconds0–3000–300 (but FIFO ordering constraint applies)How long Lambda waits to accumulate a full batch before invoking

With maximumBatchingWindowInSeconds=0 (default), Lambda invokes the function as soon as messages arrive, up to batchSize. A burst of 100 messages at once with batchSize=10 triggers 10 Lambda invocations simultaneously — each with up to 10 messages.

With maximumBatchingWindowInSeconds=30 and batchSize=10, Lambda waits up to 30 seconds or until it has 10 messages, then invokes once. This reduces invocation count but increases latency. For probe-result pipelines where low latency matters, keep the batching window at 0.

import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';

probeFunction.addEventSource(new SqsEventSource(probeQueue, {
  batchSize: 10,
  maxBatchingWindow: Duration.seconds(0),      // invoke immediately when messages arrive
  reportBatchItemFailures: true,               // enable partial batch failure response
}));

Partial batch failure: reportBatchItemFailures

By default, when a Lambda function throws an unhandled exception while processing an SQS batch, the entire batch is considered failed. All 10 messages become visible again and are reprocessed — including the 9 that succeeded. This can cause duplicate alert firing, duplicate probe result insertion, and wasted Lambda invocations.

Enabling reportBatchItemFailures (set via FunctionResponseTypes: ['ReportBatchItemFailures'] on the event source mapping, or via CDK's reportBatchItemFailures: true) lets the function return a structured response indicating which specific message IDs failed. Only those messages are retried.

// Lambda handler with partial batch failure support
import { SQSEvent, SQSBatchResponse, SQSBatchItemFailure } from 'aws-lambda';

export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
  const failures: SQSBatchItemFailure[] = [];

  for (const record of event.Records) {
    try {
      await processProbeResult(JSON.parse(record.body));
    } catch (err) {
      console.error('Failed to process message', record.messageId, err);
      failures.push({ itemIdentifier: record.messageId });
      // Do NOT rethrow — let other messages succeed
    }
  }

  return { batchItemFailures: failures };
  // Lambda retries only the failed messageIds; successful messages are deleted
};

// If all messages succeed, return empty batchItemFailures:
// { batchItemFailures: [] }  ← all messages deleted

// If you return nothing (void), all messages are treated as succeeded
// If you throw, all messages are treated as failed (retried)

Key rules for partial failure handling:

Batch size for FIFO queues

FIFO queue Lambda ESM batchSize is capped at 10. A single Lambda invocation receives up to 10 messages from one or more message groups. Within a single invocation, messages within the same group are delivered in FIFO order; across groups, ordering is not guaranteed within the batch.

For AliveMCP's per-endpoint probe pipeline with FIFO queues, a batchSize=1 gives the simplest partial-failure semantics (no batch to worry about) at the cost of 10× more Lambda invocations. A batchSize=10 with reportBatchItemFailures=true is more efficient — process each record independently in a loop and report failures individually.

Common failure modes

SymptomCauseFix
Single bad message causes all 9 healthy batch members to retryreportBatchItemFailures not enabled — Lambda throws on any error, retrying entire batchEnable reportBatchItemFailures: true and return batchItemFailures array with only the failed messageId
SendMessageBatch returns partial success with some Failed entriesIndividual messages in the batch exceeded size, had malformed deduplication IDs, or hit a service-side throttleCheck SenderFault: true = fix the message; false = retry the specific failed entries with exponential backoff
Lambda invoked with only 1–2 messages despite batchSize=10Queue has fewer than 10 messages at the time Lambda polls — Lambda invokes as soon as any messages are available, even fewer than batchSizeExpected behaviour; set maximumBatchingWindowInSeconds to accumulate a larger batch if invocation count matters more than latency
FIFO queue: message group stalled — subsequent messages not deliveredA failed message in the group was not reported in batchItemFailures, so Lambda did not retry it; or it reached maxReceiveCount and went to the DLQ, but the DLQ is not monitoredAlarm on FIFO DLQ ApproximateNumberOfMessagesVisible > 0; add manual redrive or consumer to unblock the group
DeleteMessageBatch fails for some handlesHandles expired (visibility timeout elapsed; message reappeared and was received by another consumer) or message already deletedCheck resp.Failed after DeleteMessageBatch; treat ReceiptHandleIsInvalid as benign (message already processed)