The S3 batch article is the scrappy version: run a script, list a folder, send presigned URLs to BackgroundErase, write the transparent PNGs back to S3, keep an eye on the terminal, and feel mildly powerful. This guide is the always-on version. A user uploads an image, S3 emits an event, and the system processes it without anyone running a local script at 11:47 p.m.

The boring part is "call an API from Lambda." The useful part is the production machinery around that call: queueing, idempotency, leases, rate limits, retry behavior, output naming, monitoring, and a place for weird jobs to land when they fail. That is what this article is about.

I will use the BackgroundErase API in the examples, but most of the queueing and rate-limit architecture applies to any external image-processing API. The important rule is that AWS can absorb a burst, but your downstream API quota decides how quickly that burst actually drains.

Pipeline rule

SQS is the buffer. DynamoDB is the ledger. Lambda is the worker.

  • Do not wire S3 directly to an external API for production bursts.
  • Keep SQS as the queue instead of rebuilding queue semantics in DynamoDB.
  • Use DynamoDB for idempotency, job status, leases, and rate-control state.
  • Drain at roughly one image per second per account unless your quota is higher.
  • Alert on backlog age before SQS message retention becomes the villain.

01

What we are building

We are building an event-driven image processing pipeline for new S3 uploads. A user or app uploads an image into a private input bucket or prefix. S3 sends an ObjectCreated event to SQS. A Lambda worker pulls one message at a time, creates or updates a DynamoDB job record, generates a short-lived presigned URL for the source object, calls BackgroundErase, validates the image response, and writes the processed file to an output bucket or prefix.

Architecture flow

input image upload
-> s3://my-image-pipeline-input/input/product-1.jpg
-> S3 ObjectCreated event
-> SQS job queue
-> Lambda worker
-> DynamoDB job record and rate-limit slot
-> BackgroundErase API
-> s3://my-image-pipeline-output/output/product-1.png

The input bucket stays private. The API receives a presigned URL that expires quickly, not public bucket access and not a forever URL you shoved into a database six hours before a worker finally got around to it. Store bucket, key, and version ID; generate the URL right before the API call.

This is production-ready for normal continuous uploads and large bursts that can drain within queue retention. It is not magic infinite scale, because magic infinite scale is usually just someone ignoring the slowest dependency.

02

Why not call the API directly from S3 events?

The tempting version is very short. It is also exactly the version I would avoid for a production upload path unless the volume is tiny and you are comfortable babysitting it.

The demo architecture

S3 ObjectCreated
-> Lambda
-> BackgroundErase API
-> output S3

Upload bursts create many invocations

If a customer imports 40,000 images, Lambda can try to scale far faster than the external API wants to be loved.

The API quota is smaller than Lambda

A standard BackgroundErase account allows 3 concurrent connections, a burst of 10 requests, and a refill rate of 1 request per second.

Retries can amplify the mess

A burst can cause throttling, throttling causes retries, and retries can create a second wave of traffic at the worst time.

Backlog visibility is weak

Without a queue and job ledger, it is harder to answer what is pending, what finished, what failed, and what can be replayed.

Direct S3 to Lambda is fine for a demo. It is not the architecture I would ship for a pipeline that may receive thousands or millions of images at once. Treat S3, SQS, and Lambda as at-least-once systems and design the worker so a duplicate event does not mean a duplicate API charge.

03

Production architecture

The production architecture adds two boring-looking pieces that do a lot of work: SQS and DynamoDB. SQS absorbs upload bursts and gives Lambda a backlog to drain. DynamoDB records durable job state, prevents duplicate processing, and holds the global rate-limit clock.

Service map

S3 input bucket:
  private raw uploads

SQS Standard queue:
  burst buffer and retry surface

Lambda worker:
  small processor that pulls one image job at a time

DynamoDB jobs table:
  idempotency, status, audit history, output pointers

DynamoDB rate-limit table:
  one global slot clock for the BackgroundErase API quota

S3 output bucket:
  processed PNGs, JPEGs, WebPs, or downstream-ready assets

CloudWatch:
  backlog age, failures, duration, throttles, DLQ alarms

Dead-letter queue:
  messages that failed too many times and need inspection

SQS is the queue. DynamoDB is not the primary queue here. You can build a custom DynamoDB queue, but then you own leasing, polling, retry handling, stale lock recovery, and dead-letter behavior. SQS already does that job. Let it.

DynamoDB still matters a lot. It is the difference between "some Lambdas ran" and "this exact input object version became this output object at this time, after this many attempts, with this final status." That audit trail becomes extremely nice the first time a customer asks where 138 missing images went.

Cost gut check

Most of the bill is the image API, not SQS being fancy.

These are rough monthly estimates in a single US AWS region, before AWS free tier credits. Assumptions: one input and one output object per image, about 5 MB source plus 1 MB output stored for a month, one Lambda invocation at 512 MB for about 3 seconds, roughly five DynamoDB writes and three reads per image, modest logs, one secret, and no NAT Gateway, KMS, cross-region transfer, or public egress. Translation: useful napkin math, not a bill oracle.

Cost line 1,000images/mo5,000images/mo20,000images/mo100,000images/mo1,000,000images/mo
S3 $0.15$0.73$2.91$14.56$145.57
SQS <$0.01$0.01$0.02$0.12$1.20
Lambda $0.03$0.13$0.50$2.52$25.20
DynamoDB <$0.01$0.02$0.07$0.35$3.50
Ops $2.00$2.01$2.05$2.24$4.38
AWS subtotal $2.18$2.89$5.56$19.78$179.85
BackgroundErase $30.00$70.00$220.00$700.00$2,800.00
Total $32.18$72.89$225.56$719.78$2,979.85

BackgroundErase is shown as $20/month + $0.01/image through 20,000 images/month. The 100,000 image row uses example Enterprise pricing of $300/month + $0.004/image, and the 1,000,000 image row uses $300/month + $0.0025/image. Higher volume plans can also raise throughput, which matters as much as the invoice once the backlog gets large.

04

Create the S3 buckets

Use either separate buckets or strict prefixes. Separate buckets are cleaner operationally. One bucket with input/ and output/ prefixes is also fine if the notification filter is tight. The one thing you do not want is output uploads accidentally triggering the same pipeline again. Recursive image processing is funny for about eight seconds.

S3 layout

# Two buckets
s3://backgrounderase-pipeline-input/input/product-1.jpg
s3://backgrounderase-pipeline-output/output/product-1.png

# Or one bucket with strict prefixes
s3://my-bucket/input/product-1.jpg
s3://my-bucket/output/product-1.png
01

Block Public Access

Keep it on. The API can fetch a short-lived presigned URL; the bucket itself does not need to become public.

02

Versioning

Turn it on if overwrites are possible. The worker should process the exact object version from the event when AWS gives you one.

03

Default encryption

Enable SSE-S3 or SSE-KMS. If you use KMS, remember the Lambda role needs the matching decrypt and encrypt permissions.

04

Object tags

Optional, but useful for customer IDs, upload batches, job IDs, or lifecycle rules for older raw inputs.

Versioning matters because uploads can race with processing. If a user overwrites input/product-1.jpg while the queue is backed up, the worker should not accidentally process the newer file for the older event. Include versionId in the job key whenever it is available.

05

Add the SQS queue and dead-letter queue

Create the dead-letter queue first, then create the main queue. The main queue is the one that receives S3 ObjectCreated events. The DLQ is only the destination for messages that fail too many times in the main queue. This ordering matters in the AWS Console because you usually select the DLQ while configuring the main queue's dead-letter queue settings.

SQS queue setup

Dead-letter queue:
  name: backgrounderase-image-jobs-dlq
  type: Standard
  message retention: 14 days
  encryption: SSE-SQS or SSE-KMS

Main queue:
  name: backgrounderase-image-jobs
  type: Standard
  visibility timeout: 5-10 minutes
  message retention: 14 days
  receive message wait time: 20 seconds
  dead-letter queue: backgrounderase-image-jobs-dlq
  maximum receives / maxReceiveCount: 5

If you are using the AWS Console, create backgrounderase-image-jobs-dlq from SQS first. Then create backgrounderase-image-jobs, enable the dead-letter queue option, choose the DLQ you just made, and set Maximum receives to 5. If you prefer the CLI, run the block below. It stores the returned queue URLs and ARNs in variables and uses them later in the same script, so you do not have to paste <dead-letter-queue-url> into another command. The CLI snippets in this article run inside a child Bash process, so a failed command stops that snippet without closing your current terminal session.

Create both queues with AWS CLI

bash <<'BASH'
set -euo pipefail

DLQ_NAME=backgrounderase-image-jobs-dlq
MAIN_QUEUE_NAME=backgrounderase-image-jobs

DLQ_URL=$(aws sqs create-queue \
  --queue-name "$DLQ_NAME" \
  --attributes MessageRetentionPeriod=1209600,SqsManagedSseEnabled=true \
  --query QueueUrl \
  --output text)

DLQ_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$DLQ_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

cat > main-queue-attributes.json <<JSON
{
  "VisibilityTimeout": "600",
  "MessageRetentionPeriod": "1209600",
  "ReceiveMessageWaitTimeSeconds": "20",
  "SqsManagedSseEnabled": "true",
  "RedrivePolicy": "{\"deadLetterTargetArn\":\"$DLQ_ARN\",\"maxReceiveCount\":\"5\"}"
}
JSON

MAIN_QUEUE_URL=$(aws sqs create-queue \
  --queue-name "$MAIN_QUEUE_NAME" \
  --attributes file://main-queue-attributes.json \
  --query QueueUrl \
  --output text)

MAIN_QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

printf 'Main queue URL: %s\nMain queue ARN: %s\nDLQ URL: %s\nDLQ ARN: %s\n' \
  "$MAIN_QUEUE_URL" "$MAIN_QUEUE_ARN" "$DLQ_URL" "$DLQ_ARN"
BASH

The DLQ does not need its own redrive policy. It is the parking lot for messages that the worker repeatedly failed to process. The redrive policy belongs to the main queue, and it points at the DLQ. If your main queue is Standard, use a Standard DLQ. If your main queue is FIFO, use a FIFO DLQ. For this article, both queues should be Standard.

In the AWS Console, you may not see a field literally called maxReceiveCount. That is the API and CloudFormation name. In the Console, it is usually shown under the dead-letter queue or redrive policy settings as Maximum receives. Set it to 5. That means SQS can deliver the same message to Lambda and see it fail up to five times before moving the message from backgrounderase-image-jobs to backgrounderase-image-jobs-dlq.

A value of five is a reasonable starting point for this pipeline: enough room for a transient timeout or rate-limit bump, not so much room that one corrupt image clogs the queue forever. Messages in the DLQ are not automatically fixed. Inspect the DynamoDB job record, decide whether the input is replayable, then either replay the message or mark the job permanently failed.

On the main queue, use long polling so Lambda is not constantly asking an empty queue if it has changed its mind. Set the visibility timeout longer than normal processing time, otherwise one slow job can become two workers doing the same work and acting surprised about it.

Next, let S3 send messages to the main queue. This policy belongs on backgrounderase-image-jobs, not on the DLQ and not on the S3 bucket. In the AWS Console, open SQS, choose the main queue, edit its Access policy, and grant s3.amazonaws.com permission to call sqs:SendMessage from your input bucket. The CLI block below writes the policy with your current AWS account ID and queue ARN, then applies it to the main queue.

Apply SQS queue policy for S3

bash <<'BASH'
set -euo pipefail

INPUT_BUCKET=backgrounderase-pipeline-input
MAIN_QUEUE_NAME=backgrounderase-image-jobs

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
MAIN_QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name "$MAIN_QUEUE_NAME" \
  --query QueueUrl \
  --output text)
MAIN_QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

cat > sqs-policy.json <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3ToSendObjectCreatedEvents",
      "Effect": "Allow",
      "Principal": {
        "Service": "s3.amazonaws.com"
      },
      "Action": "sqs:SendMessage",
      "Resource": "$MAIN_QUEUE_ARN",
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:s3:::$INPUT_BUCKET"
        },
        "StringEquals": {
          "aws:SourceAccount": "$ACCOUNT_ID"
        }
      }
    }
  ]
}
JSON

python3 - <<'PY'
import json

with open("sqs-policy.json") as src:
    policy = json.load(src)

with open("sqs-policy-attributes.json", "w") as dst:
    json.dump({"Policy": json.dumps(policy)}, dst)
PY

aws sqs set-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attributes file://sqs-policy-attributes.json
BASH

After the queue can accept S3 events, configure the input bucket to send ObjectCreated events into the main queue. In the AWS Console, open the input bucket, go to Properties - Event notifications, create a new notification, set the prefix to input/, choose all object-created events, choose SQS as the destination, and select backgrounderase-image-jobs. From the CLI, this block writes the bucket notification file with the correct queue ARN and applies it to backgrounderase-pipeline-input.

Apply S3 notification

bash <<'BASH'
set -euo pipefail

INPUT_BUCKET=backgrounderase-pipeline-input
MAIN_QUEUE_NAME=backgrounderase-image-jobs

MAIN_QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name "$MAIN_QUEUE_NAME" \
  --query QueueUrl \
  --output text)
MAIN_QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

cat > notification.json <<JSON
{
  "QueueConfigurations": [
    {
      "Id": "send-input-images-to-backgrounderase-queue",
      "QueueArn": "$MAIN_QUEUE_ARN",
      "Events": ["s3:ObjectCreated:*"],
      "Filter": {
        "Key": {
          "FilterRules": [
            {
              "Name": "prefix",
              "Value": "input/"
            }
          ]
        }
      }
    }
  ]
}
JSON

aws s3api put-bucket-notification-configuration \
  --bucket "$INPUT_BUCKET" \
  --notification-configuration file://notification.json
BASH

Before moving on, test the wiring while Lambda is still disconnected. Upload one harmless sample image to the input prefix and check the main queue attributes. You should see the visible message count move above zero. If it does not, fix the queue policy or bucket notification before adding a worker.

Test S3 to SQS delivery

bash <<'BASH'
set -euo pipefail

INPUT_BUCKET=backgrounderase-pipeline-input
MAIN_QUEUE_NAME=backgrounderase-image-jobs
MAIN_QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name "$MAIN_QUEUE_NAME" \
  --query QueueUrl \
  --output text)

aws s3 cp ./sample.jpg "s3://$INPUT_BUCKET/input/sample.jpg"

aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible
BASH

This notification only filters by prefix. You can add suffix rules for .jpg, .jpeg, .png, and .webp, but that usually means multiple notification rules. For this pipeline, sending every input/ object to the queue and letting the Lambda worker mark unsupported files as SKIPPED is simpler and harder to misconfigure.

06

Create the DynamoDB tables

The jobs table is the durable ledger. Do not delete successful jobs immediately. Mark them COMPLETED, store output pointers, and use TTL to expire old records later. Deleting successful jobs makes audits worse and makes duplicate events more expensive.

Create both tables with on-demand billing. The jobs table stores one row per input object version. The rate-limit table stores one small shared row used by every Lambda worker before calling the API. The script waits for each table before enabling TTL or writing the initial limiter row; DynamoDB often returns from create-table while the table is still CREATING.

Create DynamoDB tables

bash <<'BASH'
set -euo pipefail

JOBS_TABLE=BackgroundEraseJobs
RATE_LIMIT_TABLE=BackgroundEraseRateLimit

if ! aws dynamodb describe-table --table-name "$JOBS_TABLE" >/dev/null 2>&1; then
  aws dynamodb create-table \
    --table-name "$JOBS_TABLE" \
    --attribute-definitions AttributeName=PK,AttributeType=S \
    --key-schema AttributeName=PK,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST
fi

aws dynamodb wait table-exists --table-name "$JOBS_TABLE"

TTL_STATUS=$(aws dynamodb describe-time-to-live \
  --table-name "$JOBS_TABLE" \
  --query 'TimeToLiveDescription.TimeToLiveStatus' \
  --output text 2>/dev/null || true)

if [ "$TTL_STATUS" != "ENABLED" ] && [ "$TTL_STATUS" != "ENABLING" ]; then
  aws dynamodb update-time-to-live \
    --table-name "$JOBS_TABLE" \
    --time-to-live-specification "Enabled=true, AttributeName=ttl"
fi

if ! aws dynamodb describe-table --table-name "$RATE_LIMIT_TABLE" >/dev/null 2>&1; then
  aws dynamodb create-table \
    --table-name "$RATE_LIMIT_TABLE" \
    --attribute-definitions AttributeName=PK,AttributeType=S \
    --key-schema AttributeName=PK,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST
fi

aws dynamodb wait table-exists --table-name "$RATE_LIMIT_TABLE"

aws dynamodb put-item \
  --table-name "$RATE_LIMIT_TABLE" \
  --item '{"PK":{"S":"RATE_LIMIT#backgrounderase"},"next_allowed_at_ms":{"N":"0"}}'
BASH

Jobs table fields

Table: BackgroundEraseJobs

Primary key:
  PK = JOB#{bucket}#{key}#{versionId}

Attributes:
  status              PENDING | IN_PROGRESS | COMPLETED | FAILED | SKIPPED
  input_bucket
  input_key
  input_version_id
  input_etag
  input_size
  output_bucket
  output_key
  attempt_count
  last_error
  created_at
  updated_at
  lease_expires_at
  completed_at
  ttl

On every message, the worker attempts a conditional PutItem with attribute_not_exists(PK). If the item already exists and the status is COMPLETED, return success without calling the API again. If the status is IN_PROGRESS but lease_expires_at is in the past, a worker can reclaim the job. If it is FAILED, decide whether the failure was permanent or worth another attempt.

Rate-limit table fields

Table: BackgroundEraseRateLimit

Primary key:
  PK = RATE_LIMIT#backgrounderase

Attributes:
  next_allowed_at_ms

The put-item command seeds the rate limiter with next_allowed_at_ms=0. Without that row, the worker can still create it with a slightly different implementation, but seeding it up front makes the first run boring, which is the goal.

Job states

Use PENDING, IN_PROGRESS, COMPLETED, FAILED, and SKIPPED. Simple states beat a beautiful state machine nobody can query under stress.

Job key

Include bucket, key, and version ID. If versioning is off, use a stable placeholder plus the object ETag and size as extra sanity checks.

07

Configure the Lambda worker

The worker should be intentionally small and boring. It does not need to download the image unless you are doing local validation. It needs to parse the S3 event, write job state, generate a presigned URL, call the API, validate the response, upload the output, and update the job record.

01

Runtime

Python 3.12 or Node.js 20. Use whichever your team can debug fastest.

02

Timeout

2-5 minutes is a reasonable start. Do not leave it at the tiny default.

03

Memory

512 MB or 1024 MB. The API does the heavy image work here.

04

Concurrency

Set SQS event source maximum concurrency to 3. Reserved concurrency of 3 is a second safety rail.

Use SQS batch size 1. The API is the bottleneck, not Lambda throughput. A batch of ten makes partial failure handling more important and makes "which image caused this retry?" more annoying. One message per invocation is boring in the best possible way.

Create an IAM role for the function first, then create the Lambda function from lambda_worker.py. The CLI block below creates the Lambda trust policy, creates the role if it does not already exist, writes the inline permissions policy with your queue and account values, and attaches it to the role. In the Console, the same Lambda settings are under Configuration: timeout, memory, reserved concurrency, environment variables, and triggers. The trigger should be the main SQS queue, not the DLQ.

Lambda environment variables

INPUT_BUCKET=backgrounderase-pipeline-input
INPUT_PREFIX=input/
OUTPUT_BUCKET=backgrounderase-pipeline-output
OUTPUT_PREFIX=output/
JOBS_TABLE=BackgroundEraseJobs
RATE_LIMIT_TABLE=BackgroundEraseRateLimit
BACKGROUNDERASE_SECRET_NAME=backgrounderase/api-key
BACKGROUNDERASE_API_URL=https://api.backgrounderase.com/v2
PRESIGNED_URL_EXPIRATION_SECONDS=900
MAX_INPUT_FILE_SIZE_BYTES=30000000

lambda_worker.py

import json
import os
import random
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import PurePosixPath

import boto3
from botocore.exceptions import ClientError


s3 = boto3.client("s3")
ddb = boto3.resource("dynamodb")
ddb_raw = boto3.client("dynamodb")
secrets = boto3.client("secretsmanager")

INPUT_BUCKET = os.environ["INPUT_BUCKET"]
INPUT_PREFIX = os.getenv("INPUT_PREFIX", "input/")
OUTPUT_BUCKET = os.environ["OUTPUT_BUCKET"]
OUTPUT_PREFIX = os.getenv("OUTPUT_PREFIX", "output/")
JOBS_TABLE = os.environ["JOBS_TABLE"]
RATE_LIMIT_TABLE = os.environ["RATE_LIMIT_TABLE"]
SECRET_NAME = os.environ["BACKGROUNDERASE_SECRET_NAME"]
API_URL = os.getenv("BACKGROUNDERASE_API_URL", "https://api.backgrounderase.com/v2")
PRESIGN_SECONDS = int(os.getenv("PRESIGNED_URL_EXPIRATION_SECONDS", "900"))
MAX_BYTES = int(os.getenv("MAX_INPUT_FILE_SIZE_BYTES", "30000000"))

SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
LEASE_SECONDS = 8 * 60
JOB_TTL_SECONDS = 45 * 24 * 60 * 60
RATE_PK = "RATE_LIMIT#backgrounderase"

jobs = ddb.Table(JOBS_TABLE)
be_key_cache = None


class BadSource(Exception):
    pass


class TryAgain(Exception):
    pass


def lambda_handler(event, context):
    for msg in event.get("Records", []):
        handle_sqs_msg(msg)
    return {"ok": True}


def handle_sqs_msg(msg):
    body = json.loads(msg.get("body") or "{}")

    if body.get("Event") == "s3:TestEvent":
        return

    for rec in body.get("Records", []):
        handle_s3_rec(rec)


def handle_s3_rec(rec):
    seen = read_s3_event(rec)

    if seen["bucket"] != INPUT_BUCKET or not seen["key"].startswith(INPUT_PREFIX):
        return

    pk = job_pk_for(seen)
    out_key = make_out_key(seen["key"])
    seen["output_key"] = out_key

    job = create_job_if_new(pk, seen)
    status = job.get("status")

    if status == "COMPLETED":
        return

    if not file_is_supported(seen["key"]):
        mark_done(pk, "SKIPPED", "unsupported_extension", out_key)
        return

    if output_exists(out_key):
        mark_done(pk, "COMPLETED", "", out_key)
        return

    claim_job(pk)

    try:
        source = head_source(seen)
        if source["size"] > MAX_BYTES:
            raise BadSource(f"input_too_large:{source['size']}")

        update_source_bits(pk, source)
        grab_rate_slot()
        clean_bytes, clean_type = call_backgrounderase(seen)
        put_clean_file(seen, out_key, clean_bytes, clean_type, pk)
        mark_done(pk, "COMPLETED", "", out_key)
    except BadSource as err:
        mark_done(pk, "FAILED", str(err), out_key)
    except Exception as err:
        note_retry(pk, err)
        raise


def read_s3_event(rec):
    obj = rec["s3"]["object"]
    return {
        "bucket": rec["s3"]["bucket"]["name"],
        "key": urllib.parse.unquote_plus(obj["key"]),
        "version_id": obj.get("versionId") or "null",
        "etag": (obj.get("eTag") or "").strip('"'),
        "size": int(obj.get("size") or 0),
        "event_name": rec.get("eventName", ""),
    }


def job_pk_for(seen):
    return f"JOB#{seen['bucket']}#{seen['key']}#{seen['version_id']}"


def make_out_key(key):
    rel = key[len(INPUT_PREFIX) :] if key.startswith(INPUT_PREFIX) else key
    path = PurePosixPath(rel)
    name = path.name
    stem = name.rsplit(".", 1)[0] if "." in name else name
    folder = "" if str(path.parent) == "." else f"{path.parent}/"
    return f"{OUTPUT_PREFIX}{folder}{stem}.png"


def file_is_supported(key):
    return PurePosixPath(key).suffix.lower() in SUPPORTED_EXTS


def s3_params(seen):
    params = {"Bucket": seen["bucket"], "Key": seen["key"]}
    if seen["version_id"] != "null":
        params["VersionId"] = seen["version_id"]
    return params


def now_s():
    return int(time.time())


def create_job_if_new(pk, seen):
    stamp = now_s()
    item = {
        "PK": pk,
        "status": "PENDING",
        "input_bucket": seen["bucket"],
        "input_key": seen["key"],
        "input_version_id": seen["version_id"],
        "input_etag": seen["etag"],
        "input_size": seen["size"],
        "output_bucket": OUTPUT_BUCKET,
        "output_key": seen["output_key"],
        "attempt_count": 0,
        "last_error": "",
        "created_at": stamp,
        "updated_at": stamp,
        "lease_expires_at": 0,
        "ttl": stamp + JOB_TTL_SECONDS,
    }

    try:
        jobs.put_item(Item=item, ConditionExpression="attribute_not_exists(PK)")
        return item
    except ClientError as err:
        if err.response["Error"].get("Code") != "ConditionalCheckFailedException":
            raise

    got = jobs.get_item(Key={"PK": pk}, ConsistentRead=True)
    return got.get("Item") or item


def claim_job(pk):
    stamp = now_s()
    try:
        jobs.update_item(
            Key={"PK": pk},
            UpdateExpression=(
                "SET #s = :progress, updated_at = :now, "
                "lease_expires_at = :lease ADD attempt_count :one"
            ),
            ConditionExpression=(
                "#s = :pending OR #s = :failed OR "
                "(#s = :progress AND lease_expires_at < :now)"
            ),
            ExpressionAttributeNames={"#s": "status"},
            ExpressionAttributeValues={
                ":pending": "PENDING",
                ":failed": "FAILED",
                ":progress": "IN_PROGRESS",
                ":now": stamp,
                ":lease": stamp + LEASE_SECONDS,
                ":one": 1,
            },
        )
    except ClientError as err:
        if err.response["Error"].get("Code") == "ConditionalCheckFailedException":
            raise TryAgain("job_locked")
        raise


def head_source(seen):
    try:
        got = s3.head_object(**s3_params(seen))
        return {
            "size": int(got["ContentLength"]),
            "etag": got.get("ETag", "").strip('"'),
            "content_type": got.get("ContentType", ""),
        }
    except ClientError as err:
        code = err.response["Error"].get("Code")
        if code in {"404", "NoSuchKey", "NotFound"}:
            raise BadSource("source_missing")
        raise TryAgain(f"s3_head_failed:{code}")


def update_source_bits(pk, source):
    jobs.update_item(
        Key={"PK": pk},
        UpdateExpression="SET input_size = :size, input_etag = :etag, updated_at = :now",
        ExpressionAttributeValues={
            ":size": source["size"],
            ":etag": source["etag"],
            ":now": now_s(),
        },
    )


def output_exists(out_key):
    try:
        s3.head_object(Bucket=OUTPUT_BUCKET, Key=out_key)
        return True
    except ClientError as err:
        code = err.response["Error"].get("Code")
        if code in {"404", "NoSuchKey", "NotFound"}:
            return False
        raise TryAgain(f"s3_output_head_failed:{code}")


def grab_rate_slot():
    while True:
        now_ms = int(time.time() * 1000)
        got = ddb_raw.get_item(
            TableName=RATE_LIMIT_TABLE,
            Key={"PK": {"S": RATE_PK}},
            ConsistentRead=True,
        )
        item = got.get("Item", {})
        old_next = int(item.get("next_allowed_at_ms", {}).get("N", now_ms))
        slot_start = max(now_ms, old_next)
        new_next = slot_start + 1000

        if now_ms < old_next:
            time.sleep(min((old_next - now_ms) / 1000, 5))

        try:
            ddb_raw.update_item(
                TableName=RATE_LIMIT_TABLE,
                Key={"PK": {"S": RATE_PK}},
                UpdateExpression="SET next_allowed_at_ms = :new_next",
                ConditionExpression=(
                    "attribute_not_exists(next_allowed_at_ms) "
                    "OR next_allowed_at_ms = :old_next"
                ),
                ExpressionAttributeValues={
                    ":old_next": {"N": str(old_next)},
                    ":new_next": {"N": str(new_next)},
                },
            )
            return
        except ClientError as err:
            if err.response["Error"].get("Code") != "ConditionalCheckFailedException":
                raise
            time.sleep(random.uniform(0.05, 0.3))


def call_backgrounderase(seen):
    url = s3.generate_presigned_url(
        "get_object",
        Params=s3_params(seen),
        ExpiresIn=PRESIGN_SECONDS,
    )
    fields = {
        "image_url": url,
        "format": "png",
        "channels": "rgba",
    }
    body, content_type = make_multipart(fields)
    req = urllib.request.Request(
        API_URL,
        data=body,
        method="POST",
        headers={
            "x-api-key": be_api_key(),
            "Content-Type": content_type,
            "Accept": "image/*",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            img = resp.read()
            got_type = resp.headers.get("Content-Type", "")
    except urllib.error.HTTPError as err:
        api_body = err.read().decode("utf-8", "ignore")[:800]
        if err.code == 429:
            wait = retry_after_seconds(err.headers.get("Retry-After"))
            if wait:
                time.sleep(min(wait, 15))
            raise TryAgain(f"backgrounderase_429:{api_body}")
        if err.code in {400, 413, 415, 422}:
            raise BadSource(f"backgrounderase_rejected:{err.code}:{api_body}")
        if err.code == 408 or 500 <= err.code <= 599:
            raise TryAgain(f"backgrounderase_retryable:{err.code}:{api_body}")
        raise TryAgain(f"backgrounderase_unexpected:{err.code}:{api_body}")
    except urllib.error.URLError as err:
        raise TryAgain(f"backgrounderase_network:{err.reason}")

    if not img or not got_type.startswith("image/"):
        raise TryAgain(f"backgrounderase_non_image:{got_type}")

    return img, got_type


def make_multipart(fields):
    boundary = f"----be-{uuid.uuid4().hex}"
    parts = []
    for name, value in fields.items():
        parts.append(f"--{boundary}".encode())
        parts.append(f'Content-Disposition: form-data; name="{name}"'.encode())
        parts.append(b"")
        parts.append(str(value).encode())
    parts.append(f"--{boundary}--".encode())
    parts.append(b"")
    return b"\r\n".join(parts), f"multipart/form-data; boundary={boundary}"


def retry_after_seconds(value):
    if not value:
        return 0
    try:
        return max(0, int(value))
    except ValueError:
        return 0


def be_api_key():
    global be_key_cache
    if be_key_cache:
        return be_key_cache

    raw = secrets.get_secret_value(SecretId=SECRET_NAME).get("SecretString", "")
    try:
        parsed = json.loads(raw)
        be_key_cache = parsed.get("BACKGROUNDERASE_API_KEY") or parsed.get("api_key") or raw
    except json.JSONDecodeError:
        be_key_cache = raw.strip()
    return be_key_cache


def put_clean_file(seen, out_key, img, content_type, pk):
    s3.put_object(
        Bucket=OUTPUT_BUCKET,
        Key=out_key,
        Body=img,
        ContentType="image/png" if content_type.startswith("image/") else content_type,
        Metadata={
            "source-bucket": seen["bucket"],
            "source-key": urllib.parse.quote(seen["key"], safe="/-_.~"),
            "source-version-id": seen["version_id"],
            "backgrounderase-job-id": pk,
        },
    )


def mark_done(pk, status, err_text, out_key):
    stamp = now_s()
    expression = (
        "SET #s = :status, updated_at = :now, last_error = :err, "
        "lease_expires_at = :zero, output_key = :out"
    )
    values = {
        ":status": status,
        ":now": stamp,
        ":err": err_text[:900],
        ":zero": 0,
        ":out": out_key,
    }

    if status == "COMPLETED":
        expression += ", completed_at = :now"

    jobs.update_item(
        Key={"PK": pk},
        UpdateExpression=expression,
        ExpressionAttributeNames={"#s": "status"},
        ExpressionAttributeValues=values,
    )


def note_retry(pk, err):
    jobs.update_item(
        Key={"PK": pk},
        UpdateExpression="SET updated_at = :now, last_error = :err, lease_expires_at = :zero",
        ExpressionAttributeValues={
            ":now": now_s(),
            ":err": str(err)[:900],
            ":zero": 0,
        },
    )

Create Lambda role and policy

bash <<'BASH'
set -euo pipefail

INPUT_BUCKET=backgrounderase-pipeline-input
OUTPUT_BUCKET=backgrounderase-pipeline-output
MAIN_QUEUE_NAME=backgrounderase-image-jobs
ROLE_NAME=backgrounderase-image-worker-role
JOBS_TABLE=BackgroundEraseJobs
RATE_LIMIT_TABLE=BackgroundEraseRateLimit
SECRET_NAME=backgrounderase/api-key

REGION=$(aws configure get region)
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
if [ -z "$REGION" ]; then
  echo "Set AWS_REGION or configure a default AWS CLI region first." >&2
  exit 1
fi
MAIN_QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name "$MAIN_QUEUE_NAME" \
  --query QueueUrl \
  --output text)
MAIN_QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

cat > lambda-trust-policy.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
JSON

if ! aws iam get-role --role-name "$ROLE_NAME" >/dev/null 2>&1; then
  aws iam create-role \
    --role-name "$ROLE_NAME" \
    --assume-role-policy-document file://lambda-trust-policy.json
fi

aws iam wait role-exists --role-name "$ROLE_NAME"

cat > lambda-worker-policy.json <<JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::$INPUT_BUCKET/input/*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:HeadObject"],
      "Resource": "arn:aws:s3:::$OUTPUT_BUCKET/output/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage",
        "sqs:ChangeMessageVisibility",
        "sqs:GetQueueAttributes"
      ],
      "Resource": "$MAIN_QUEUE_ARN"
    },
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
      "Resource": [
        "arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/$JOBS_TABLE",
        "arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/$RATE_LIMIT_TABLE"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:$REGION:$ACCOUNT_ID:secret:$SECRET_NAME-*"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "*"
    }
  ]
}
JSON

aws iam put-role-policy \
  --role-name "$ROLE_NAME" \
  --policy-name backgrounderase-image-worker-inline \
  --policy-document file://lambda-worker-policy.json

sleep 10

aws iam get-role --role-name "$ROLE_NAME" --query 'Role.Arn' --output text
BASH

After the role exists and lambda_worker.py is saved locally, deploy the function. Do not connect the SQS trigger yet; give the function its environment first so the first queued message does not become a preventable error. The deploy block waits for the function to become active before setting reserved concurrency, because Lambda also has a short "resource is still updating" window.

Deploy Lambda worker

bash <<'BASH'
set -euo pipefail

ROLE_NAME=backgrounderase-image-worker-role
ROLE_ARN=$(aws iam get-role \
  --role-name "$ROLE_NAME" \
  --query 'Role.Arn' \
  --output text)

zip function.zip lambda_worker.py

if aws lambda get-function --function-name backgrounderase-image-worker >/dev/null 2>&1; then
  aws lambda update-function-code \
    --function-name backgrounderase-image-worker \
    --zip-file fileb://function.zip

  aws lambda wait function-updated \
    --function-name backgrounderase-image-worker
else
  aws lambda create-function \
    --function-name backgrounderase-image-worker \
    --runtime python3.12 \
    --handler lambda_worker.lambda_handler \
    --role "$ROLE_ARN" \
    --timeout 300 \
    --memory-size 1024 \
    --zip-file fileb://function.zip

  aws lambda wait function-active \
    --function-name backgrounderase-image-worker
fi

aws lambda put-function-concurrency \
  --function-name backgrounderase-image-worker \
  --reserved-concurrent-executions 3
BASH

Then add the environment variables shown above in the Lambda Console. If you are staying in the CLI, create the API key secret and set the function environment like this. Keep the API key in Secrets Manager under backgrounderase/api-key; do not paste it directly into the Lambda environment.

Set Lambda environment and API key

bash <<'BASH'
set -euo pipefail

read -rsp "BackgroundErase API key: " BE_API_KEY < /dev/tty
printf '\n'

if aws secretsmanager describe-secret --secret-id backgrounderase/api-key >/dev/null 2>&1; then
  aws secretsmanager put-secret-value \
    --secret-id backgrounderase/api-key \
    --secret-string "$BE_API_KEY"
else
  aws secretsmanager create-secret \
  --name backgrounderase/api-key \
    --secret-string "$BE_API_KEY"
fi

aws lambda update-function-configuration \
  --function-name backgrounderase-image-worker \
  --environment 'Variables={INPUT_BUCKET=backgrounderase-pipeline-input,INPUT_PREFIX=input/,OUTPUT_BUCKET=backgrounderase-pipeline-output,OUTPUT_PREFIX=output/,JOBS_TABLE=BackgroundEraseJobs,RATE_LIMIT_TABLE=BackgroundEraseRateLimit,BACKGROUNDERASE_SECRET_NAME=backgrounderase/api-key,BACKGROUNDERASE_API_URL=https://api.backgrounderase.com/v2,PRESIGNED_URL_EXPIRATION_SECONDS=900,MAX_INPUT_FILE_SIZE_BYTES=30000000}'

aws lambda wait function-updated \
  --function-name backgrounderase-image-worker
BASH

Once the configuration update succeeds, connect the main SQS queue as the trigger. Use batch size 1 and maximum concurrency 3 on the event source mapping. If you rerun this block later, it updates the existing mapping instead of trying to create a duplicate one.

Connect SQS trigger

bash <<'BASH'
set -euo pipefail

MAIN_QUEUE_NAME=backgrounderase-image-jobs
MAIN_QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name "$MAIN_QUEUE_NAME" \
  --query QueueUrl \
  --output text)
MAIN_QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url "$MAIN_QUEUE_URL" \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)

MAPPING_UUID=$(aws lambda list-event-source-mappings \
  --function-name backgrounderase-image-worker \
  --event-source-arn "$MAIN_QUEUE_ARN" \
  --query 'EventSourceMappings[0].UUID' \
  --output text)

if [ "$MAPPING_UUID" = "None" ] || [ -z "$MAPPING_UUID" ]; then
  aws lambda create-event-source-mapping \
    --function-name backgrounderase-image-worker \
    --event-source-arn "$MAIN_QUEUE_ARN" \
    --batch-size 1 \
    --scaling-config MaximumConcurrency=3
else
  aws lambda update-event-source-mapping \
    --uuid "$MAPPING_UUID" \
    --batch-size 1 \
    --scaling-config MaximumConcurrency=3
fi
BASH

After this step, the pipeline is wired up. A new object under input/ should create an SQS message, Lambda should receive that message, and the worker should write the processed file under output/. The rest of the article is not more required setup; it explains what the worker is doing and what you should monitor before trusting it with real volume.

08

Lambda worker explanation

The worker file is doing several jobs that are easy to miss if you only look at the AWS wiring: global rate limiting, presigned URL generation, API response validation, idempotent output writes, and failure classification. You do not need to run more setup commands in this section. This is the map for debugging and modifying lambda_worker.py.

Rate limiting

The concurrency cap handles maximum simultaneous connections. It does not enforce one request per second. Three Lambdas can still wake up at the same time and send three requests in the same second, which is how you get avoidable 429s and then a little retry bonfire.

BackgroundErase account constraints used in this article

  • Token bucket burst capacity: 10 requests
  • Refill rate: 1 request per second
  • Maximum concurrent connections: 3 per account

Use a DynamoDB-backed next-slot limiter. In the worker file above, that is the grab_rate_slot() function. Every worker has to reserve the next global API slot before it calls BackgroundErase. If another worker wins the conditional update, the loser waits and tries again.

To verify it is working, watch the Lambda logs during a burst. You should see workers wait around the rate slot instead of all calling the API at once. If a deployment bug writes a timestamp far into the future, reset the limiter row by setting next_allowed_at_ms back to 0 in BackgroundEraseRateLimit.

This limiter is stricter than a true token bucket because it does not use the full burst capacity of 10. That is acceptable for a production tutorial because correctness matters more than squeezing out the first ten requests. If you need the burst behavior, store token count and last refill time in DynamoDB and use conditional updates around both values.

The API can still return 429. Respect Retry-After when it is present, use exponential backoff with jitter, and let SQS retry later when the response is clearly transient.

Presigned URLs and API calls

Do not put presigned URLs in SQS or DynamoDB. They can expire while waiting in the queue, especially during a large import. Store only the bucket, key, version ID, ETag, size, and job metadata. Generate the URL immediately before the API call.

This happens inside call_backgrounderase(seen) in lambda_worker.py. The function generates the presigned URL, builds the multipart request, sends image_url, format=png, and channels=rgba, then rejects empty or non-image responses.

Presign and call the API

def call_backgrounderase(seen):
    url = s3.generate_presigned_url(
        "get_object",
        Params=s3_params(seen),
        ExpiresIn=PRESIGN_SECONDS,
    )
    fields = {
        "image_url": url,
        "format": "png",
        "channels": "rgba",
    }
    body, content_type = make_multipart(fields)
    req = urllib.request.Request(
        API_URL,
        data=body,
        method="POST",
        headers={
            "x-api-key": be_api_key(),
            "Content-Type": content_type,
            "Accept": "image/*",
        },
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        img = resp.read()
        got_type = resp.headers.get("Content-Type", "")

    if not img or not got_type.startswith("image/"):
        raise TryAgain(f"backgrounderase_non_image:{got_type}")

    return img, got_type

For the basic background-removal output, send image_url, format=png, and channels=rgba. If you need a white-background JPEG, thumbnail WebP, or a different crop policy, that belongs in your output recipe. This article is focused on the pipeline, not every possible image setting.

Validate status

Require HTTP 200 before treating the response as a successful image.

Validate content type

Require Content-Type to start with image/.

Validate body

Do not upload an empty response body and call it a day.

Validate source

Reject files above your configured size limit before spending API work on them.

Output writes

Write the processed file to the output bucket or prefix with a predictable key. A simple first pass is to mirror the input key and change the extension to .png. In a product catalog, you may also include customer ID, SKU, output recipe, dimensions, or a source version hash.

In the worker file, make_out_key() decides the output path and put_clean_file() uploads the result. The key detail is that output_exists() runs before the API call. That makes retries cheaper when the API succeeded but the final job update failed.

Upload output object

def put_clean_file(seen, out_key, img, content_type, pk):
    s3.put_object(
        Bucket=OUTPUT_BUCKET,
        Key=out_key,
        Body=img,
        ContentType="image/png" if content_type.startswith("image/") else content_type,
        Metadata={
            "source-bucket": seen["bucket"],
            "source-key": urllib.parse.quote(seen["key"], safe="/-_.~"),
            "source-version-id": seen["version_id"],
            "backgrounderase-job-id": pk,
        },
    )

Before calling the API, check whether the expected output object already exists. This matters for idempotency and for the annoying edge case where the API succeeds but the final S3 upload or DynamoDB update fails. On retry, you want to notice the output and mark the job complete instead of paying for another identical API call.

If your output upload can trigger S3 events, make sure the input notification only watches the input prefix. Otherwise your clean images can become new inputs, which is a very efficient way to generate a bill and no joy.

Failures and retries

Failure handling should be boring and explicit. Split errors into transient, permanent, and ambiguous. Then make the Lambda either raise so SQS retries the message, or mark the job terminal and return success so the message is removed.

In lambda_worker.py, permanent failures raise BadSource and are marked FAILED or SKIPPED. Retryable failures raise a normal exception after note_retry() records the last error, which causes Lambda to return a failure to SQS. SQS then redelivers the message until Maximum receives is hit.

Failure policy

Transient:
  429 rate limit
  408 timeout
  500-599 API response
  network timeout
  temporary S3 or DynamoDB failure
  -> raise so SQS retries later

Permanent:
  unsupported file type
  object too large
  invalid image
  known API validation error
  -> mark FAILED or SKIPPED and return success

Ambiguous:
  API returned non-image body
  output upload failed after API success
  unexpected exception
  DynamoDB state conflict
  -> retry a few times, then inspect in the DLQ

Permanent errors should not keep retrying for fourteen days just because the queue is technically capable of it. Mark corrupt files, unsupported formats, objects above your maximum size, and known API validation errors as FAILED or SKIPPED in DynamoDB, then return success.

Ambiguous errors deserve a few retries and then the DLQ. When a message lands in the DLQ, inspect the DynamoDB job record and the last error before replaying it. Blind DLQ replay is how teams rediscover the same permanent error with more confidence and less patience.

09

Monitoring and alarms

A production pipeline should tell you two things quickly: is work draining, and are failures accumulating somewhere quiet? CloudWatch metrics are not glamorous, but the queue age graph will save you from pretending a million-image import is "almost done" when it is actually on day nine.

CloudWatch alarms to create

SQS ApproximateNumberOfMessagesVisible > expected backlog
SQS ApproximateAgeOfOldestMessage > 10 days
DLQ ApproximateNumberOfMessagesVisible > 0
Lambda Errors > 0
Lambda Throttles > 0
Lambda Duration p95 near timeout
Lambda ConcurrentExecutions > 3
DynamoDB ReadThrottleEvents or WriteThrottleEvents > 0
BackgroundErase API 200 count drops unexpectedly
BackgroundErase API 429 count > 0
BackgroundErase API 5xx count > 0
Jobs COMPLETED count falls below expected drain rate
Jobs FAILED count rises above normal baseline

The most important alarm is ApproximateAgeOfOldestMessage > 10 days. SQS message retention maxes out at 14 days. If the oldest message is already 10 days old, you are running out of runway and need to raise quota, add a reconciliation job, or stop feeding the queue until it catches up.

Also alarm when the DLQ has any visible messages. A DLQ is not a trash can. It is a shelf where bad jobs wait for a human to decide whether they are replayable, permanently failed, or evidence that your code did something exciting.

Create at least the queue-age alarm and the DLQ alarm before you let real uploads hit the bucket. This CLI block creates an SNS topic, asks for an alert email address, subscribes that email, and uses the returned topic ARN as the alarm action. AWS will send a confirmation email; the alarms will not notify you until that subscription is confirmed.

Create the first CloudWatch alarms

bash <<'BASH'
set -euo pipefail

MAIN_QUEUE_NAME=backgrounderase-image-jobs
DLQ_NAME=backgrounderase-image-jobs-dlq

read -rp "Alert email address: " ALERT_EMAIL < /dev/tty

SNS_TOPIC_ARN=$(aws sns create-topic \
  --name backgrounderase-pipeline-alerts \
  --query TopicArn \
  --output text)

aws sns subscribe \
  --topic-arn "$SNS_TOPIC_ARN" \
  --protocol email \
  --notification-endpoint "$ALERT_EMAIL"

aws cloudwatch put-metric-alarm \
  --alarm-name backgrounderase-main-queue-oldest-message-over-10-days \
  --namespace AWS/SQS \
  --metric-name ApproximateAgeOfOldestMessage \
  --dimensions Name=QueueName,Value="$MAIN_QUEUE_NAME" \
  --statistic Maximum \
  --period 300 \
  --evaluation-periods 1 \
  --threshold 864000 \
  --comparison-operator GreaterThanThreshold \
  --treat-missing-data notBreaching \
  --alarm-actions "$SNS_TOPIC_ARN"

aws cloudwatch put-metric-alarm \
  --alarm-name backgrounderase-dlq-has-messages \
  --namespace AWS/SQS \
  --metric-name ApproximateNumberOfMessagesVisible \
  --dimensions Name=QueueName,Value="$DLQ_NAME" \
  --statistic Maximum \
  --period 300 \
  --evaluation-periods 1 \
  --threshold 0 \
  --comparison-operator GreaterThanThreshold \
  --treat-missing-data notBreaching \
  --alarm-actions "$SNS_TOPIC_ARN"
BASH

10

Limitations of this setup

SQS can absorb a large upload burst. That does not mean the images process instantly. With the default BackgroundErase quota in this article, the pipeline should intentionally drain at about one image per second per account unless your account has a higher API quota.

Backlog Time at 1 request/sec
10,000 images about 2.8 hours
100,000 images about 27.8 hours
1,000,000 images about 11.6 days
2,000,000 images about 23.1 days

This table is the part people like to skip because it is rude. A million-image backlog is about 11.6 days at one request per second. That is technically inside 14-day SQS retention if nothing else goes wrong, but it is close enough to the edge that I would want a higher API quota or a backfill strategy before calling it safe.

The API rate limit is the true bottleneck

AWS may buffer the work, but the BackgroundErase quota controls the drain rate.

SQS retention is finite

Message retention maxes out at 14 days. If backlog drain time exceeds retention, messages can expire.

Duplicates are possible

S3 events, SQS, and Lambda should all be treated as at-least-once. Idempotency is not optional.

Ordering is not guaranteed

SQS Standard does not give strict ordering. Use S3 version IDs if overwrites matter.

Lambda has a ceiling

It is fine for short external API jobs, but not ideal for huge downloads or long local transforms.

Presigned URLs expire

Generate them right before the API call. Do not store them as queue payloads.

DynamoDB rate limiting is stateful

Conditional conflicts are normal. A bad timestamp bug can stall workers, so log and keep an operational reset path.

DLQ replay needs care

Inspect the job record and error first. Permanent errors do not become temporary because you clicked replay.

Costs grow with objects

S3, SQS, Lambda, DynamoDB, CloudWatch, Secrets Manager, and API usage all scale with volume.

Massive imports need a plan

If the backlog may exceed retention, use quota increases, reconciliation, requeueing, or separate batch processing.

If you expect backlogs larger than what can drain within retention, pick one or more of these: request a higher BackgroundErase API quota, use S3 Inventory plus a scheduled backfill or reconciliation job, keep DynamoDB as the durable job ledger and periodically requeue pending work, or run a separate batch processing job for historical imports.

11

When to use a different architecture

S3 to SQS to Lambda is a good default for continuous uploads, short jobs, and an external API bottleneck. It is not the only good answer. Pick the architecture based on the type of work, not because an architecture diagram looked tidy.

Use the local batch script

Best for one-time supervised cleanup, modest image counts, and cases where you want a human watching progress.

Use S3, SQS, and Lambda

Best when new images arrive continuously, jobs are short, and API quota is the main throughput limit.

Use S3 Inventory plus a batch job

Best for existing massive buckets, reconciliation, and backfills that may exceed SQS retention.

Use ECS, Fargate, or AWS Batch

Best for long-running jobs, persistent worker pools, heavy local image processing, or tighter control over runtime behavior.

Use Step Functions

Best when each image needs branching, human review, multiple downstream actions, or state-machine visibility.

Build for the backlog, not the happy-path upload.

The happy path is one image landing in S3 and one clean output appearing a few seconds later. Production is the day a customer imports 400,000 photos, one file is corrupt, the API returns a few 429s, someone overwrites a key, and your future self needs to explain what happened. SQS, DynamoDB, rate limits, and alarms are how you make that day less cursed.

Read the API docs
Maxwell Meyer

Written by

Maxwell Meyer

Cofounder at BackgroundErase

Maxwell is a cofounder of BackgroundErase, where he works on image-processing research and developer infrastructure.