At 2:07 a.m., our nightly reconciliation job looked healthy on paper. The dashboard showed “running,” nobody had paged us yet, and the coffee machine was still warm from the previous false alarm. Then the support chat lit up: customer invoices were missing for one region, duplicated for another, and one batch had vanished in the middle. We had outgrown our old “one Lambda, one giant file, pray for retries” design, and that night made it obvious.
By sunrise, we moved to an AWS Step Functions Distributed Map workflow for fan-out processing, added strict Lambda idempotency, and put a guardrail around poison messages with an SQS dead-letter queue. The result was not magic, but it was calm: predictable retries, visible failures, and clean redrive paths for bad records.
If you are doing serverless batch processing with unpredictable file sizes, this is the architecture I wish I had adopted earlier.
The architectural shift that stopped batch chaos
Our old pattern failed for three reasons: no safe fan-out model, retries without context, and no durable handling for messages that repeatedly fail. Distributed Map solved the first problem by letting us run each item as a child workflow with high concurrency and separate execution history. AWS documentation also highlights that this mode is designed for large datasets (including S3-backed inputs) and can run up to 10,000 child workflows when concurrency is not explicitly capped.
The second fix was disciplined retry behavior. Step Functions supports retry policies with interval, backoff, max attempts, max delay, and jitter. Adding jitter matters in production because synchronized retries can stampede downstream dependencies.
The third fix was operational hygiene: if an item keeps failing, move it out of the hot path. SQS dead-letter queues are purpose-built for this, and AWS recommends sizing maxReceiveCount high enough for meaningful retries while keeping a clear analysis and redrive workflow.
Reference workflow: Distributed Map + bounded retries + failure threshold
This state machine reads records from S3, processes each item in distributed mode, and writes failures to a remediation path when tolerated failure thresholds are crossed.
{
"Comment": "Batch invoice enrichment with Distributed Map",
"StartAt": "ProcessRecords",
"States": {
"ProcessRecords": {
"Type": "Map",
"Label": "InvoiceMap",
"ItemReader": {
"Resource": "arn:aws:states:::s3:getObject",
"ReaderConfig": {
"InputType": "CSV",
"CSVHeaderLocation": "FIRST_ROW"
},
"Parameters": {
"Bucket": "acme-batch-input",
"Key": "nightly/invoices.csv"
}
},
"ItemProcessor": {
"ProcessorConfig": {
"Mode": "DISTRIBUTED",
"ExecutionType": "STANDARD"
},
"StartAt": "EnrichInvoice",
"States": {
"EnrichInvoice": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:ap-south-1:123456789012:function:invoice-enricher",
"Payload.$": "$"
},
"OutputPath": "$.Payload",
"Retry": [
{
"ErrorEquals": ["Lambda.ServiceException", "Lambda.SdkClientException", "States.Timeout"],
"IntervalSeconds": 2,
"BackoffRate": 2,
"MaxAttempts": 4,
"MaxDelaySeconds": 30,
"JitterStrategy": "FULL"
}
],
"End": true
}
}
},
"MaxConcurrency": 500,
"ToleratedFailurePercentage": 2,
"Catch": [
{
"ErrorEquals": ["States.ExceedToleratedFailureThreshold"],
"Next": "SendToOpsQueue"
}
],
"End": true
},
"SendToOpsQueue": {
"Type": "Task",
"Resource": "arn:aws:states:::sqs:sendMessage",
"Parameters": {
"QueueUrl": "https://sqs.ap-south-1.amazonaws.com/123456789012/batch-ops-dlq",
"MessageBody.$": "$"
},
"End": true
}
}
}
Tradeoff to be explicit about: high concurrency is tempting, but your downstream limits still win. In practice, we set MaxConcurrency based on database write capacity and Lambda reserved concurrency, then scaled up deliberately during low-risk windows.
Idempotent Lambda handler that survives duplicate delivery
A retry-safe orchestrator is only half the story. Your function must treat duplicate events as expected behavior, not edge cases. The simplest pattern is a DynamoDB conditional write on a stable idempotency key.
import hashlib
import json
import os
import time
import boto3
from botocore.exceptions import ClientError
ddb = boto3.client("dynamodb")
TABLE = os.environ["IDEMPOTENCY_TABLE"]
TTL_SECONDS = 24 * 60 * 60
def idempotency_key(record: dict) -> str:
raw = f"{record['invoice_id']}|{record['region']}|{record['billing_date']}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def claim_once(key: str) -> bool:
now = int(time.time())
try:
ddb.put_item(
TableName=TABLE,
Item={
"id": {"S": key},
"created_at": {"N": str(now)},
"expires_at": {"N": str(now + TTL_SECONDS)}
},
ConditionExpression="attribute_not_exists(id)"
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise
def handler(event, context):
key = idempotency_key(event)
if not claim_once(key):
return {"status": "duplicate_ignored", "idempotency_key": key}
# Domain work (enrichment, validation, write to ledger)
# Keep this section side-effect aware and timeout bounded.
result = {
"invoice_id": event["invoice_id"],
"status": "processed",
"amount": event.get("amount")
}
print(json.dumps({"level": "INFO", "event": "invoice_processed", "result": result}))
return result
This pattern does not eliminate every failure mode. It does, however, make retries deterministic and cheap to reason about, which is exactly what you want in serverless batch processing.
Where this fits with your existing 7Tech stack
If you are already tuning cloud cost and reliability, this model complements a few pieces you may have read on 7Tech:
- Cloud cost optimization in 2026 for setting practical spend guardrails while increasing orchestration quality.
- AWS Lambda cold start techniques for latency-sensitive portions of your pipeline.
- Zero-trust internal API patterns when batch workflows call protected internal services.
- Idempotent processing patterns that translate cleanly from webhook systems into batch systems.
Troubleshooting in production: what breaks first
1) Sudden spike in partial failures
Symptom: Map Run starts failing with States.ExceedToleratedFailureThreshold.
Usually means: data quality drift, changed schema, or a downstream dependency timing out.
Fix: lower MaxConcurrency temporarily, inspect failed items, and route malformed records to a quarantine queue for replay after validation rules are patched.
2) Duplicate writes despite retries being configured
Symptom: same invoice or entity appears twice in downstream storage.
Usually means: no stable idempotency key, or key derived from mutable data.
Fix: key from immutable business identifiers, not timestamps generated at runtime.
3) DLQ keeps growing and never drains
Symptom: operational queue accumulates failures faster than triage.
Usually means: maxReceiveCount and retry strategy mismatch, or no redrive runbook.
Fix: define ownership, automate redrive in controlled batches, and set CloudWatch alarms on DLQ depth and age.
FAQ
Q1: When should I choose Distributed Map over a regular Map state?
Use Distributed Map when dataset size, execution history, or required concurrency outgrows inline Map limits. It is especially useful for S3-backed datasets and large fan-out processing.
Q2: Should I always push failed items to an SQS dead-letter queue?
For most asynchronous batch workloads, yes. A DLQ gives you an auditable lane for investigation and redrive. For strict-order FIFO flows, be careful, because DLQ handling can affect ordering assumptions.
Q3: Is idempotency still needed if Step Functions already retries safely?
Absolutely. Orchestrator-level retries and function-level idempotency solve different problems. Without idempotency, retries can still create duplicate side effects.
Actionable takeaways
- Adopt AWS Step Functions Distributed Map when your workload crosses simple inline fan-out boundaries.
- Treat retries as a design component, not an afterthought, and use jitter to avoid synchronized retry storms.
- Implement Lambda idempotency with conditional persistence before writing side effects.
- Wire an SQS dead-letter queue with clear alarms, owners, and a tested redrive runbook.
- Throttle concurrency to match downstream capacity, then scale safely with data from real Map Run metrics.
That 2 a.m. incident was not caused by “bad serverless.” It was caused by pretending batch jobs are linear when they are probabilistic at scale. Once we designed for retries, duplicates, and failure visibility from day one, operations stopped feeling like gambling and started feeling like engineering.

Leave a Reply