Guide · Message Queue

MCP Tools for AWS SQS — visibility timeout, long polling, and DLQ redrive

AWS Simple Queue Service (SQS) delivers at-least-once message delivery through a visibility timeout mechanism rather than consumer acknowledgment state tracked by the broker. When an MCP tool receives a message from SQS, that message does not disappear from the queue — it becomes invisible to other consumers for the visibility timeout duration. If the tool does not delete the message before the timeout expires, SQS makes the message visible again and delivers it to another consumer. Three non-obvious properties drive correct SQS tooling: ApproximateNumberOfMessages vs ApproximateNumberOfMessagesNotVisible (the first is backlog, the second is in-flight — the sum is the consumer lag signal), long polling with WaitTimeSeconds=20 (essential for cost and latency, not optional), and DLQ redrive configuration (the DLQ is a separate queue with its own attributes — monitoring just the source queue misses silent message loss).

TL;DR

Create an SQS client with the AWS SDK, always set WaitTimeSeconds: 20 on ReceiveMessage to use long polling and avoid empty-poll charges. Delete messages explicitly after processing — never rely on them "disappearing" automatically. For health monitoring, call GetQueueAttributes and check both ApproximateNumberOfMessages (backlog) and ApproximateNumberOfMessagesNotVisible (in-flight). For DLQ health, always look up the DLQ URL from the source queue's RedrivePolicy attribute and check it separately — a non-zero ApproximateNumberOfMessages on the DLQ means processing failures that require human review. Register an HTTP health bridge with AliveMCP to detect SQS consumer failures independently of the consumer process itself.

Visibility timeout: SQS's delivery guarantee mechanism

SQS does not use a traditional acknowledge model where the broker tracks which consumer received each message. Instead, it uses visibility timeout: when a consumer calls ReceiveMessage, the returned messages become invisible to all other consumers for the VisibilityTimeout period (1 second to 12 hours, default 30 seconds). The consumer must call DeleteMessage before the timeout expires. If it does not, the message becomes visible again and will be redelivered — possibly to a different consumer.

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

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

async function processMessages(queueUrl) {
  const response = await sqs.send(new ReceiveMessageCommand({
    QueueUrl: queueUrl,
    MaxNumberOfMessages: 10,      // max 10 per API call
    WaitTimeSeconds: 20,          // long polling — ALWAYS set this
    VisibilityTimeout: 300,       // 5 minutes — must exceed max processing time
    MessageAttributeNames: ['All'],
    AttributeNames: ['ApproximateReceiveCount', 'SentTimestamp']
  }));

  if (!response.Messages?.length) return; // empty poll result

  for (const msg of response.Messages) {
    const receiveCount = parseInt(msg.Attributes?.ApproximateReceiveCount ?? '1');
    const ageMs = Date.now() - parseInt(msg.Attributes?.SentTimestamp ?? '0');

    try {
      await handleMessage(msg.Body, receiveCount, ageMs);
      // Delete ONLY after successful processing
      await sqs.send(new DeleteMessageCommand({
        QueueUrl: queueUrl,
        ReceiptHandle: msg.ReceiptHandle
      }));
    } catch (err) {
      // On failure, make the message immediately visible again (don't wait for timeout)
      await sqs.send(new ChangeMessageVisibilityCommand({
        QueueUrl: queueUrl,
        ReceiptHandle: msg.ReceiptHandle,
        VisibilityTimeout: 0  // immediately visible = fast retry
      }));
    }
  }
}

The ReceiptHandle is a temporary, opaque token issued by SQS for each message delivery. It is not the message ID — the same message gets a different ReceiptHandle each time it is received. ReceiptHandles are valid only for the duration of the visibility timeout; using an expired ReceiptHandle in a DeleteMessage call returns an ReceiptHandleIsInvalid error. An MCP tool must store the ReceiptHandle alongside any work done with the message content, not just the MessageId.

Extending the visibility timeout mid-processing is critical for long-running operations. If an MCP tool is processing a message that takes 8 minutes but the queue's visibility timeout is 5 minutes, the message will be redelivered 3 minutes into processing. Use ChangeMessageVisibility to extend the timeout before it expires:

async function processWithHeartbeat(queueUrl, message, processor) {
  const visibilitySeconds = 300; // 5 minutes initial
  let extendInterval;

  try {
    // Extend visibility every 4 minutes (before the 5-minute timeout)
    extendInterval = setInterval(async () => {
      await sqs.send(new ChangeMessageVisibilityCommand({
        QueueUrl: queueUrl,
        ReceiptHandle: message.ReceiptHandle,
        VisibilityTimeout: visibilitySeconds // reset to 5 more minutes
      }));
    }, 4 * 60 * 1000);

    await processor(message.Body);

    await sqs.send(new DeleteMessageCommand({
      QueueUrl: queueUrl,
      ReceiptHandle: message.ReceiptHandle
    }));
  } finally {
    clearInterval(extendInterval);
  }
}

Queue depth metrics: ApproximateNumberOfMessages vs NotVisible

SQS exposes queue depth through the GetQueueAttributes API, not a dedicated metrics endpoint. The three key attributes:

async function getQueueHealth(queueUrl) {
  const attrs = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: queueUrl,
    AttributeNames: [
      'ApproximateNumberOfMessages',
      'ApproximateNumberOfMessagesNotVisible',
      'ApproximateNumberOfMessagesDelayed',
      'RedrivePolicy',
      'VisibilityTimeout',
      'MessageRetentionPeriod',
      'QueueArn'
    ]
  }));

  const a = attrs.Attributes ?? {};
  const ready = parseInt(a.ApproximateNumberOfMessages ?? '0');
  const inflight = parseInt(a.ApproximateNumberOfMessagesNotVisible ?? '0');
  const delayed = parseInt(a.ApproximateNumberOfMessagesDelayed ?? '0');

  const redrivePolicy = a.RedrivePolicy ? JSON.parse(a.RedrivePolicy) : null;
  const dlqArn = redrivePolicy?.deadLetterTargetArn;

  return {
    queue_url: queueUrl,
    messages_ready: ready,
    messages_inflight: inflight,
    messages_delayed: delayed,
    total_messages: ready + inflight + delayed,
    visibility_timeout_sec: parseInt(a.VisibilityTimeout ?? '30'),
    message_retention_days: parseInt(a.MessageRetentionPeriod ?? '345600') / 86400,
    dlq_configured: dlqArn != null,
    dlq_arn: dlqArn,
    max_receive_count: redrivePolicy?.maxReceiveCount,
    // Consumer starvation: messages accumulating but nothing processing
    // Cannot detect directly (no consumer count in SQS) — infer from rising inflight with no delete rate
    suspected_starvation: ready > 100 && inflight === 0,
  };
}

Unlike RabbitMQ, SQS does not expose a consumer count metric — consumers are ephemeral Lambda invocations or EC2 processes that SQS does not track. Consumer starvation (no active consumers) must be inferred by watching ApproximateNumberOfMessages grow without a corresponding increase in ApproximateNumberOfMessagesNotVisible. If messages are ready but nothing is receiving them, the in-flight count stays at zero while ready grows.

SQS's maximum in-flight message limit for standard queues is 120,000 messages. Exceeding this limit causes ReceiveMessage to return an OverLimit error. For FIFO queues, the limit is 20,000. An MCP tool operating near these limits must implement backpressure by reducing consumer parallelism.

DLQ redrive: configuration and monitoring

The Dead Letter Queue in SQS is configured via the source queue's RedrivePolicy attribute, which specifies the DLQ's ARN and the maxReceiveCount. When a message's ApproximateReceiveCount reaches maxReceiveCount, SQS moves it to the DLQ automatically. The DLQ is a completely separate SQS queue with its own URL, ARN, and attributes.

async function getDLQHealth(queueUrl) {
  const sourceAttrs = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: queueUrl,
    AttributeNames: ['RedrivePolicy', 'QueueArn']
  }));

  const redrivePolicy = JSON.parse(sourceAttrs.Attributes?.RedrivePolicy ?? 'null');
  if (!redrivePolicy) {
    return { dlq_configured: false };
  }

  // DLQ ARN → URL requires a separate API call
  // ARN format: arn:aws:sqs:{region}:{account-id}:{queue-name}
  const dlqArn = redrivePolicy.deadLetterTargetArn;
  const arnParts = dlqArn.split(':');
  const dlqRegion = arnParts[3];
  const dlqAccount = arnParts[4];
  const dlqName = arnParts[5];

  // GetQueueUrl converts name to URL
  const { QueueUrl: dlqUrl } = await sqs.send(
    new GetQueueUrlCommand({ QueueName: dlqName })
  );

  const dlqAttrs = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: dlqUrl,
    AttributeNames: ['ApproximateNumberOfMessages', 'ApproximateNumberOfMessagesNotVisible']
  }));

  const dlqDepth = parseInt(dlqAttrs.Attributes?.ApproximateNumberOfMessages ?? '0');
  const dlqInflight = parseInt(dlqAttrs.Attributes?.ApproximateNumberOfMessagesNotVisible ?? '0');

  return {
    dlq_configured: true,
    dlq_url: dlqUrl,
    max_receive_count: redrivePolicy.maxReceiveCount,
    dlq_depth: dlqDepth,
    dlq_inflight: dlqInflight,
    // Non-zero DLQ depth means permanent processing failures requiring investigation
    dlq_has_messages: dlqDepth > 0 || dlqInflight > 0,
  };
}

A non-zero DLQ depth is a strong signal that requires human intervention. Messages land in the DLQ only after failing maxReceiveCount delivery attempts — this is not a transient condition. The message body and attributes in the DLQ retain the original content; the ApproximateReceiveCount attribute on DLQ messages represents the count since entering the DLQ (which is typically 1 if you have a consumer draining the DLQ for retry).

SQS DLQ Redrive allows you to move DLQ messages back to the source queue for reprocessing, without writing a consumer that reads from the DLQ and republishes. This is available in the Console and via the StartMessageMoveTask API (2023+), which accepts a source (DLQ ARN) and destination (source queue ARN). An MCP tool exposing a "retry DLQ messages" capability should use this API rather than implementing a manual read-publish loop.

Long polling: WaitTimeSeconds is not optional

SQS short polling (the default when WaitTimeSeconds is omitted or 0) returns immediately even if no messages are available, sampling only a subset of the distributed SQS servers. This produces two problems: empty poll responses cost the same as polls with messages (you pay per API call), and in-flight messages may not be visible because they are on servers not sampled in the short poll.

Long polling (WaitTimeSeconds=1 to 20, recommended: 20) makes SQS wait up to the specified duration for a message to arrive before returning an empty response. It queries all SQS servers in the round, eliminating the sampling problem. Costs are dramatically lower for low-throughput queues because you make one API call every 20 seconds instead of one every 100ms in a polling loop.

// WRONG: short polling loop (expensive and misses messages)
while (true) {
  const r = await sqs.send(new ReceiveMessageCommand({ QueueUrl })); // no WaitTimeSeconds
  for (const msg of r.Messages ?? []) await processAndDelete(msg);
  await new Promise(r => setTimeout(r, 100)); // rate limit with sleep
}

// CORRECT: long polling (efficient and complete)
while (running) {
  const r = await sqs.send(new ReceiveMessageCommand({
    QueueUrl,
    WaitTimeSeconds: 20,          // block up to 20s if no messages
    MaxNumberOfMessages: 10,
    VisibilityTimeout: 300
  }));
  // No sleep needed — WaitTimeSeconds provides the natural rate limit
  for (const msg of r.Messages ?? []) {
    processAndDelete(msg); // can process in parallel, do not await here
  }
}

Queue-level long polling can also be configured via the ReceiveMessageWaitTimeSeconds queue attribute, which sets the default WaitTimeSeconds for all ReceiveMessage calls. Per-request WaitTimeSeconds overrides the queue attribute. For MCP tools, always set it explicitly per-request to avoid depending on queue configuration that may change.

FIFO queues: deduplication and message groups

SQS FIFO queues (queue names must end in .fifo) provide exactly-once processing within a 5-minute deduplication window and strict ordering within message groups. They require two additional parameters on every SendMessage call:

async function sendFIFOMessage(queueUrl, payload, groupId) {
  const deduplicationId = crypto.randomUUID(); // or hash of content for idempotent sends

  await sqs.send(new SendMessageCommand({
    QueueUrl: queueUrl,
    MessageBody: JSON.stringify(payload),
    MessageGroupId: groupId,
    MessageDeduplicationId: deduplicationId,
    MessageAttributes: {
      'schema-version': { DataType: 'String', StringValue: '2' }
    }
  }));
  return deduplicationId; // store for idempotency tracking
}

FIFO queues have a maximum throughput of 300 messages/second (standard API) or 3,000 messages/second (high throughput FIFO, enabled via queue attribute SqsManagedSseEnabled or console). The in-flight limit is 20,000 messages total. FIFO queues do not support delay queues (DelaySeconds must be 0) and cannot be used with SNS standard topics (only SNS FIFO topics).

CloudWatch metrics: the consumer health picture SQS doesn't show directly

SQS's GetQueueAttributes shows point-in-time counts but not rates over time. CloudWatch metrics provide the time-series view required for trend-based alerting:

import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch';

const cloudwatch = new CloudWatchClient({ region: process.env.AWS_REGION });

async function getQueueMetricTrend(queueName, metricName, periodSeconds = 300, lookbackMinutes = 60) {
  const endTime = new Date();
  const startTime = new Date(endTime - lookbackMinutes * 60 * 1000);

  const result = await cloudwatch.send(new GetMetricStatisticsCommand({
    Namespace: 'AWS/SQS',
    MetricName: metricName,
    Dimensions: [{ Name: 'QueueName', Value: queueName }],
    StartTime: startTime,
    EndTime: endTime,
    Period: periodSeconds,
    Statistics: ['Maximum'] // Maximum is more useful than Average for age metrics
  }));

  return result.Datapoints
    .sort((a, b) => a.Timestamp - b.Timestamp)
    .map(p => ({ time: p.Timestamp, value: p.Maximum }));
}

// Usage: check if oldest message age is growing (consumer falling behind)
const ageTrend = await getQueueMetricTrend(queueName, 'ApproximateAgeOfOldestMessage');

Frequently asked questions

What happens if I don't delete an SQS message after processing?

If you receive a message but do not call DeleteMessage before the visibility timeout expires, SQS makes the message visible again and will deliver it to the next consumer that polls the queue. This is the at-least-once delivery guarantee — messages are redelivered until explicitly deleted. The ApproximateReceiveCount attribute (accessible via AttributeNames: ['ApproximateReceiveCount']) increments on each delivery. When this count reaches the queue's maxReceiveCount (set in the RedrivePolicy), the message is moved to the DLQ. For MCP tools, always delete messages as part of your processing — if your tool crashes mid-processing without deleting, the visibility timeout expiry is your safety net, but it increases latency. Set the visibility timeout to exceed your 99th-percentile processing time by at least 2x.

Why does SQS report ApproximateNumberOfMessages=0 when I know messages were sent?

SQS reports approximate values due to its distributed nature. Three scenarios cause ApproximateNumberOfMessages to show 0 when messages exist: (1) The messages are in-flight — received by a consumer but not yet deleted, counted in ApproximateNumberOfMessagesNotVisible instead. (2) The messages are delayed — if the queue or individual messages have a DelaySeconds set, they are counted in ApproximateNumberOfMessagesDelayed and not yet visible. (3) Eventual consistency lag — the attribute is updated asynchronously and may lag by a few seconds after a burst of sends. Always check all three attributes and sum them for total queue depth. For FIFO queues in particular, a message group being actively processed may show 0 in ready/delayed even though more messages for that group are queued — they are blocked behind the in-flight message for ordering purposes.

How do I choose the right visibility timeout for my SQS queue?

Set the visibility timeout to your 99th-percentile message processing time plus a safety margin. Too short: messages are redelivered mid-processing, causing duplicate work or corrupted state if your processing is not idempotent. Too long: when a consumer crashes, the message is invisible to other consumers until the timeout expires, causing processing delays. A common pattern: set the queue's default VisibilityTimeout to 5 minutes, and for long-running tasks, extend it mid-processing using ChangeMessageVisibility before it expires. The maximum visibility timeout is 12 hours. For Lambda triggers, SQS automatically extends the visibility timeout to the Lambda function's configured timeout — you do not need to manage it manually when using Lambda as the consumer.

What is the difference between SQS standard queues and FIFO queues?

Standard queues offer higher throughput (unlimited messages per second), best-effort ordering (messages may arrive out of order), and at-least-once delivery (rare duplicates can occur). FIFO queues offer guaranteed ordering within a message group, exactly-once processing within a 5-minute deduplication window, and lower throughput (300-3,000 messages/second depending on configuration). For MCP tools, choose FIFO when the business logic requires sequential processing (e.g., account balance updates, order state transitions) where reordering would cause correctness bugs. Choose standard queues when ordering doesn't matter and you need higher throughput (e.g., log collection, analytics events, notification delivery). FIFO queues cannot be used with SNS standard topics, and FIFO queue names must end with .fifo.

How do I implement idempotent message processing to handle SQS redeliveries?

Idempotent processing means that processing the same message twice produces the same result as processing it once. Three approaches: (1) Natural idempotency — if your operation is naturally idempotent (e.g., setting a value, not incrementing it), no additional work is needed. (2) Deduplication table — store the MessageId in a database with a TTL matching the message retention period. At the start of processing, check if the ID exists; if so, skip and delete. This works but requires an external database. (3) Conditional writes — use database-level conditional updates (e.g., DynamoDB condition expressions, SQL INSERT ... ON CONFLICT DO NOTHING) that succeed only if the record doesn't already exist or is in the expected state. The MessageId is stable across redeliveries — it is assigned when the message is first sent and does not change, making it a reliable deduplication key. Do not use ReceiptHandle for deduplication — it changes on each delivery.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free