Node.js Serverless on AWS Lambda · Event-Driven Lambda
S3 Events, Duplicates and Recursive Loops
How an image thumbnailer can invoke itself millions of times, and how to handle S3 events safely.
Scenario
Example app: users upload photos to an S3 bucket; an S3 event notification triggers a Lambda function that creates a thumbnail and writes it back to the same bucket under thumbnails/.
What you see in production
Within an hour of deployment the Lambda bill and S3 request count explode. The bucket fills with thumbnails/thumbnails/thumbnails/... objects. Separately, some uploads produce two thumbnails from two invocations.
The naive code
The notification fires for every ObjectCreated event in the bucket:
export const handler = async (event) => {
for (const r of event.Records) {
const key = decodeURIComponent(r.s3.object.key.replace(/\+/g, " "));
const image = await s3.getObject({ Bucket: r.s3.bucket.name, Key: key });
await s3.putObject({ Bucket: r.s3.bucket.name, Key: `thumbnails/${key}`, Body: await resize(image) });
}
};
Reproduce it
function simulate({ prefixFilter, maxInvocations = 25 }) {
const queue = ["uploads/cat.jpg"];
const written = [];
let invocations = 0;
while (queue.length && invocations < maxInvocations) {
const key = queue.shift();
if (prefixFilter && !key.startsWith(prefixFilter)) continue; // notification filter
invocations++;
const thumb = `thumbnails/${key}`;
written.push(thumb);
queue.push(thumb); // writing creates a new ObjectCreated event
}
const last = written.at(-1);
return { invocations, nestedThumbnailFolders: (last.match(/thumbnails\//g) || []).length };
}
console.log("no filter: ", simulate({}));
console.log("prefix 'uploads/': ", simulate({ prefixFilter: "uploads/" }));
Output
no filter: { invocations: 25, nestedThumbnailFolders: 25 }
prefix 'uploads/': { invocations: 1, nestedThumbnailFolders: 1 }Root cause
Writing output into the same bucket and prefix that triggers the function creates a recursive loop: each output object generates a new event. S3 event notifications are also delivered at least once and are not ordered, so the same upload can trigger more than one invocation. Lambda detects and stops some recursive loops between supported services, but you should never depend on that safety net.
The fix
- Write outputs to a different bucket, or at least scope the notification with a prefix/suffix filter (
uploads/,.jpg) that output keys never match. - Make processing idempotent: derive the output key deterministically from the input (
thumbnails/<input key>) and skip work if a newer output already exists, or record processed object versions. - Decode keys correctly (URL-encoded,
+for spaces), and check object size before loading it into memory. - For heavy processing, route events through SQS (or EventBridge) to get batching, retries, a DLQ and concurrency control.
Trade-offs
- A separate bucket doubles some configuration (policies, lifecycle) but removes a whole class of loops.
- EventBridge notifications for S3 allow richer filtering and multiple targets but add a hop.
- Idempotency checks cost an extra request; deterministic output keys usually make them cheap.
Detect it in production
- Sudden, self-sustaining growth in invocations and S3 PUT requests; set budget and invocation alarms.
- Lambda's recursive loop detection notifications and the
RecursiveInvocationsDroppedmetric. - Object keys with repeated prefixes.
Key points
- Never write outputs where they re-trigger the function; use another bucket or strict filters.
- S3 events are at-least-once and unordered: be idempotent.
- Use SQS between S3 and heavy processing for retries and control.
Further reading: Process S3 event notifications with Lambda · Lambda recursive loop detection
Practice questions
How can an S3-triggered function end up invoking itself repeatedly?
Answer
If it writes its output to a location that matches the same event notification (same bucket and prefix), each output object creates a new event that triggers the function again, forming a recursive loop.
Why must S3 event handlers be idempotent?
Answer
S3 event notifications are delivered at least once and are not guaranteed to be in order, so the same object event can invoke the function more than once.
Why might you put SQS between S3 events and a processing function?
Answer
To gain batching, controlled concurrency, retries with a dead-letter queue and buffering during spikes, instead of S3 invoking the function directly for every object.
Open in the app to mark lessons complete, take notes and track your progress.