Skip to content

AWS Lambda + Pinpoint SMS throttling issue with async parallel processing (.NET 6/8)

0

I am building a scheduled SMS processing system using AWS Lambda + Amazon Pinpoint SMS Voice V2 in C# (.NET 6/8).

Flow:

EventBridge Scheduler -> SchedulerProcess Lambda -> invokes TargetAPI Lambda asynchronously -> TargetAPI Lambda sends SMS using Pinpoint I use a US toll-free number which has a strict limit of 3 MPS (messages per second).

My goal is:

Scheduler Lambda should dispatch requests quickly Each TargetAPI Lambda should run independently in background No waiting for Lambda completion Avoid deadlocks/throttling Handle future scale (5k+ scheduled SMS)

Current scheduler code:

foreach (var request in dbResponse.Data) { try { LambdaInvokeRequest payload = new LambdaInvokeRequest { Resource = "/{proxy+}", Path = _configuration["AppData:targetAPI"], HttpMethod = HttpMethod.Post.ToString(), Headers = new Headers { ContentType = "application/json" }, Body = request };

    InvokeRequest invokeReq = new InvokeRequest
    {
        FunctionName = _configuration["AppData:APIFunction"],
        InvocationType = InvocationType.Event,
        Payload = JsonConvert.SerializeObject(payload)
    };
    _ = _amazonLambdaClient.InvokeAsync(invokeReq);
    await Task.Delay(334);
}
catch (Exception ex)
{
    // logging
}

} Inside TargetAPI Lambda: sendTextMessageResponse = _amazonPinpointSMSVoiceV2Client .SendTextMessageAsync(sendTextMessageRequest) .Result; I also implemented retry logic for throttling: catch (AggregateException agEx) { Exception innerEx = agEx.InnerException; if (innerEx is AmazonPinpointSMSVoiceV2Exception pinpointEx && (pinpointEx.ErrorCode == "ThrottlingException" || pinpointEx.Message.Contains("Rate Exceeded"))) { await Task.Delay(totalDelay); } }

Problems:

  1. Task.Run() If I use Task.Run(), the scheduler Lambda finishes execution too early and some Lambda invocations never happen.

  2. Parallel.ForEachAsync() If I use parallel processing with MaxDegreeOfParallelism, Lambda invocations work, but too many concurrent requests hit: SendTextMessageAsync() which causes Pinpoint throttling and retry failures.

  3. Performance issue Current performance: 100 SMS ≈ 3 minutes 40 second Production could have 5000+ SMS Estimated runtime becomes several hours

Expected behavior: foreach dispatches one Lambda every 334ms Each Lambda runs independently No waiting for SMS delivery Scheduler finishes quickly

100 SMS ≈ 33 seconds dispatch time 5000 SMS ≈ ~28 minutes dispatch time Questions: How can I make this truly async and scalable without losing requests?

Tech stack: AWS Lambda Amazon Pinpoint SMS Voice V2 EventBridge Schedule DynamoDB C# .NET 6/8

Additional note: I intentionally used: await Task.Delay(334); because the toll-free number allows only 3 MPS, so I tried spacing requests manually. However, concurrent Lambda executions still cause throttling when many requests arrive simultaneously.

3 Answers
1
Accepted Answer

The two answers above point in the right direction (move rate control out of the dispatcher, use SQS), but there are a few things worth tightening up — both in your current code and in the proposed SQS pattern. None of them are obvious from the docs alone.

What's actually broken in the dispatcher

_ = _amazonLambdaClient.InvokeAsync(invokeReq);
await Task.Delay(334);

The discarded Task is the bug. InvokeAsync returns a Task<InvokeResponse> that you never await. When the scheduler Lambda exits, the .NET runtime has no obligation to wait for in-flight tasks — the runtime is frozen, and any TCP request that hadn't completed its TLS handshake or HTTP write is dropped. That's why "some Lambda invocations never happen" with Task.Run. Same root cause here: fire-and-forget against the Lambda Invoke endpoint is not safe inside a Lambda.

You need either:

  • await _amazonLambdaClient.InvokeAsync(invokeReq) in the loop, or
  • collect the tasks and await Task.WhenAll(...) at the end.

The InvocationType.Event is doing the async-on-the-server-side work — you don't need fire-and-forget on the client side too.

Why MaximumConcurrency=3 doesn't give you 3 MPS

The Generative AI answer suggests MaximumConcurrency=3, BatchSize=1 on an SQS event source mapping. Two issues:

  1. The minimum allowed value is 2, not 1, and the valid range is 2–1000 (ScalingConfig API ref). MaximumConcurrency=3 is allowed, but if you ever wanted "exactly 1 concurrent invocation" for strict serialization you'd use reserved concurrency = 1 on the function instead.
  2. Concurrency ≠ throughput. Real TPS is concurrency ÷ average_invocation_duration_seconds. If SendTextMessageAsync takes 200 ms (warm path), 3 concurrent invocations send ~15 MPS — Pinpoint will throttle. If it takes 2 s (.NET cold start + cluster setup), you get 1.5 MPS — under-utilization. With a 3 MPS hard limit you cannot let concurrency-driven scaling do the rate control alone.

A pattern that actually pins you to 3 MPS

The cleanest serverless pattern for "deterministic 3 MPS, 5000+ items" is SQS FIFO + a single-concurrency Lambda that does the spacing internally:

  • SQS FIFO queue with a single MessageGroupId (e.g. the toll-free number itself). FIFO + one group = strictly serial dispatch.
  • Lambda with reserved concurrency = 1, BatchSize 5–10. Inside the handler, iterate the batch and await Task.Delay(334) between each SendTextMessageAsync. This guarantees at most ~3 MPS regardless of cold starts, retries, or scaling oddities.
  • Async/await all the way down. Drop .Result — it's both a deadlock risk and the reason your catch (AggregateException) exists. With await, the exception is AmazonPinpointSMSVoiceV2Exception directly, no unwrap needed.

This trades raw throughput for predictability. For 5000 messages you're at 5000/3 ≈ 28 minutes — same as the documented limit, but without retry storms.

When you outgrow this — Step Functions Distributed Map

If 28 minutes is too slow, the only way out is more origination numbers (e.g. 10DLC at 1 MPS per number, or a short code at 100 MPS). Do not purchase additional toll-free numbers and round-robin across them — carriers detect this as "snowshoeing" and filter your traffic.

Once you have multiple origination IDs, Step Functions Distributed Map is a better orchestration layer than a single Lambda dispatcher: native fan-out up to 10,000 child executions, per-state MaxConcurrency, automatic CSV/S3 ingestion for batch jobs, and built-in retry/checkpoint without writing the bookkeeping yourself.

Other things worth doing for production

  • Configuration set + event destinations. Without these you can't tell whether a SendTextMessage "success" actually delivered. CloudWatch Logs or Firehose destinations are the minimum for production SMS — see Set up logging for AWS End User Messaging SMS.
  • Make the SDK client static. IAmazonPinpointSMSVoiceV2 should be a singleton across invocations (initialize outside the handler). Building it per call adds 200–500 ms.
  • Pinpoint EOL. AWS will end support for Amazon Pinpoint on 30 October 2026. The v2 API you're using is the migration target (rebranded as AWS End User Messaging SMS), so you're on the right path — but if any other part of your stack still touches Pinpoint endpoints/segments/campaigns, plan that migration now.

Hope this helps.

AWS

answered 2 months ago

AWS
EXPERT

reviewed 18 days ago

1

You may consider implementing centralized rate‑limiting (queue or token bucket) instead of relying on Task.Delay inside each Lambda. To achieve this, using SQS/Kinesis or Pinpoint configuration sets to control throughput, since Pinpoint enforces strict MPS (messages per second) quotas at the origination number level.

https://docs.aws.amazon.com/cli/latest/reference/pinpoint-sms-voice-v2/

https://docs.aws.amazon.com/pinpoint/latest/developerguide/sms-voice-v2.html

EXPERT

answered 2 months ago

  • Thanks will try

  • Thoughtfull, thanks

-2

Your architecture has a fundamental mismatch between your dispatch rate and Pinpoint's rate limits. Here's how to fix it:

Core Problem

You're dispatching Lambdas rapidly (every 334ms), but each Lambda immediately calls Pinpoint. With asynchronous invocations, multiple TargetAPI Lambdas execute concurrently, all hitting Pinpoint simultaneously. Your toll-free number's 3 MPS limit means only 3 message parts can be sent per second, but you're creating far more concurrent requests than that.

Recommended Solution: Use SQS as a Rate-Limiting Buffer

Architecture:

  1. SchedulerProcess Lambda → writes messages to SQS queue (fast)
  2. SQS → triggers TargetAPI Lambda with controlled concurrency
  3. TargetAPI Lambda → sends via Pinpoint

Implementation:

Step 1: Remove the delay from your scheduler and write to SQS instead:

foreach (var request in dbResponse.Data)
{
var message = new SendMessageRequest
{
QueueUrl = queueUrl,
MessageBody = JsonConvert.SerializeObject(request)
};
await sqsClient.SendMessageAsync(message);
}

Step 2: Configure SQS Event Source Mapping on TargetAPI Lambda:

  • Set MaximumConcurrency to 3 (matching your 3 MPS limit)
  • Set BatchSize to 1
  • Enable ScalingConfig with MaximumConcurrency: 3

This ensures only 3 Lambda instances process messages concurrently, naturally rate-limiting your Pinpoint calls.

Step 3: In TargetAPI Lambda, use proper async/await:

// Remove .Result - use await instead
sendTextMessageResponse = await _amazonPinpointSMSVoiceV2Client
.SendTextMessageAsync(sendTextMessageRequest);

Why This Works

The SQS event source mapping acts as a natural throttle. Lambda will only invoke up to 3 concurrent executions of your TargetAPI function, which aligns with your 3 MPS limit. Messages queue up in SQS and are processed at a sustainable rate.

For 5000 messages at 3 MPS: ~28 minutes processing time (which matches your expectation).

Additional Optimizations

Batch Processing: If your messages are short (single message parts), you could increase BatchSize to process multiple messages per invocation, then send them sequentially with delays:

foreach (var record in sqsEvent.Records)
{
await SendSmsAsync(record);
await Task.Delay(334); // Space out sends within the Lambda
}

Monitor Throttling: Track asynchronous invocation metrics like AsyncEventAge and AsyncEventReceived to detect if your queue is backing up due to insufficient concurrency or if invocations are being throttled.

Concurrency Management: Lambda has account-level concurrency limits (1000 per region by default). With this SQS approach, you can set reserved concurrency on TargetAPI Lambda to ensure it doesn't consume all available concurrency, while the event source mapping's MaximumConcurrency controls the rate.

Avoid These Patterns

  • Don't use Task.Run() for Lambda invocations - it creates fire-and-forget tasks that may not complete before the scheduler Lambda terminates
  • Don't use .Result - it blocks threads and can cause deadlocks in async contexts
  • Don't manually invoke Lambdas in a loop - this creates uncontrolled concurrency

The SQS-based approach gives you true async processing with built-in rate limiting, automatic retries, and dead-letter queue support for failed messages.
Sources
What are the Message Parts per Second (MPS) limits - AWS End User Messaging SMS
Optimize message delivery to third-party services using AWS Lambda and AWS Step Functions | Integration & Automation

answered 2 months ago

You are not logged in. Log in to post an answer.

A good answer clearly answers the question and provides constructive feedback and encourages professional growth in the question asker.