Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 035 / 100

S3 event notifications: a practical guide for developers

React to uploads automatically.

Expona Admin9 min read

Introduction

S3 buckets sit at the heart of most AWS architectures, but they're often treated as passive storage. The moment you need to react to uploads—whether that's generating thumbnails, scanning for malware, or triggering downstream workflows—you need to know about S3 event notifications. This guide covers S3 event notifications: a practical guide for developers who want to automate file processing without polling and keep their systems responsive at scale.

We've handled real production scenarios where a single misplaced filter rule caused millions of unnecessary Lambda invocations, and where missing idempotency checks led to duplicate processing. Let's build systems that actually work.

Understanding S3 event notifications

S3 can emit events when objects are created, deleted, or restored from Glacier. These events contain the bucket name, object key, event time, and metadata. The key advantage over polling is immediate reaction: your handlers fire within seconds of the upload completing.

S3 supports three destination types: Lambda, SQS, and SNS. EventBridge integration is also available as a filter-rich alternative. For most teams, you'll reach for Lambda for lightweight processing or SQS when you need reliable buffering and retry logic.

Event notifications are best-effort delivery. AWS guarantees that at least one notification will be sent for each event, but in rare cases you may receive duplicates. This is why idempotency matters, which we'll cover in depth later.

Triggering Lambda, SQS, or EventBridge

Lambda: Direct and immediate

Lambda is the natural choice for synchronous processing where latency isn't a concern and you have predictable volume. The event payload arrives directly in your handler, and you can process it immediately.

bash

{
  "Records": [
    {
      "eventVersion": "2.0",
      "eventSource": "aws:s3",
      "awsRegion": "eu-west-1",
      "eventTime": "2025-01-15T10:30:45.123Z",
      "eventName": "ObjectCreated:Put",
      "s3": {
        "bucket": {
          "name": "my-uploads",
          "arn": "arn:aws:s3:::my-uploads"
        },
        "object": {
          "key": "documents/invoice-2025-01-15.pdf",
          "size": 256000,
          "eTag": "\"abc123def456\""
        }
      }
    }
  ]
}

A basic Lambda handler in Node.js might look like this:

bash

exports.handler = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key);
    console.log(`Processing ${key} from ${bucket}`);
    
    // Your processing logic here
    // Download from S3, transform, store result
  }
  return { statusCode: 200 };
};

Set up the S3 notification in the AWS console or via Infrastructure as Code. We recommend Terraform or CDK to avoid manual drift.

SQS: Reliable buffering for high volume

When upload traffic spikes or your processor needs retry logic, SQS provides a buffer. S3 sends event notifications to an SQS queue, and your workers consume messages at their own pace. This decoupling prevents Lambda throttling and gives you built-in retry mechanics.

The SQS message body contains the S3 event payload. Your consumer can process it, and if processing fails, the message returns to the queue for retry. After the visibility timeout expires (default 30 seconds), the message reappears for another attempt.

EventBridge: Filtering and routing at scale

EventBridge sits between S3 and your targets, letting you filter events before they trigger expensive Lambda invocations. This is particularly valuable when you only care about specific file types or prefixes.

An EventBridge rule might route only PDFs from a certain prefix to one Lambda, images to a thumbnail generator, and everything else to a DLQ for manual review. The filtering happens before invocation, saving both cost and latency.

Generate thumbnails and scan files

Real-world scenario: Image uploads

Consider an e-commerce platform where users upload product images. When a JPG or PNG lands in S3, you want to generate thumbnails (small, medium, large) and extract metadata like dimensions and format.

Your Lambda receives the S3 event, downloads the original image, uses a library like Sharp to resize it, and uploads the three thumbnail variants back to S3. All of this can happen within the Lambda timeout (we'd allocate 30 seconds and 512 MB memory for this).

bash

const AWS = require('aws-sdk');
const sharp = require('sharp');

const s3 = new AWS.S3();

exports.handler = async (event) => {
  const bucket = event.Records[0].s3.bucket.name;
  const key = decodeURIComponent(event.Records[0].s3.object.key);

  // Download original
  const obj = await s3.getObject({ Bucket: bucket, Key: key }).promise();
  const buffer = obj.Body;

  // Generate thumbnails
  const sizes = [
    { name: 'small', width: 100, height: 100 },
    { name: 'medium', width: 300, height: 300 },
    { name: 'large', width: 800, height: 800 }
  ];

  for (const size of sizes) {
    const thumb = await sharp(buffer)
      .resize(size.width, size.height, { fit: 'contain', background: '#fff' })
      .toBuffer();

    const thumbKey = key.replace(/\.[^.]+$/, `-${size.name}.jpg`);
    await s3.putObject({
      Bucket: bucket,
      Key: thumbKey,
      Body: thumb,
      ContentType: 'image/jpeg'
    }).promise();
  }

  console.log(`Generated thumbnails for ${key}`);
};

File scanning with third-party APIs

For security-sensitive workloads, you'll scan uploads for malware or sensitive data. Services like ClamAV or Rapid7 can be invoked from Lambda. The pattern is the same: receive the event, fetch the file, scan it, and store the result (clean/infected) in a metadata table.

Always add error handling. If scanning fails, you might want to quarantine the file and alert your security team rather than silently ignoring the error.

Filter by prefix and suffix

Avoiding unnecessary invocations

Not every object in your bucket needs processing. User profile pictures and system logs might live in the same bucket but require different handling.

S3 event notification filters let you specify key prefixes and suffixes. Set up one filter for uploads/images/ to trigger the thumbnail generator, and another for uploads/documents/ with suffix .pdf to trigger a document processor.

Use case Filter prefix Filter suffix Destination
Product images products/ .jpg, .png Thumbnail Lambda
User uploads user-uploads/ .pdf Document scanner
Logs logs/ .log CloudWatch Logs

Filtering at the S3 layer is cheaper than filtering in your Lambda code. If you configure the prefix correctly, you'll avoid invoking Lambda thousands of times per day for objects you don't care about.

Multi-destination routing with EventBridge

If your filtering logic gets complex—e.g., process images only if they're under 5 MB and not in a quarantine folder—use EventBridge rules. You can match on object key patterns and route to different targets.

bash

{
  "Name": "route-s3-events",
  "EventBusName": "default",
  "EventPattern": {
    "source": ["aws.s3"],
    "detail-type": ["Object Created"],
    "detail": {
      "bucket": {
        "name": ["my-uploads"]
      },
      "object": {
        "key": [{
          "prefix": "products/",
          "suffix": ".jpg"
        }]
      }
    }
  },
  "Targets": [
    {
      "Arn": "arn:aws:lambda:eu-west-1:123456789:function:thumbnail-generator",
      "RoleArn": "arn:aws:iam::123456789:role/EventBridgeRole"
    }
  ]
}

Make handlers idempotent

Why idempotency is non-negotiable

S3 event notifications guarantee at-least-once delivery. If a network glitch occurs after your Lambda processes an upload but before S3 marks the notification as sent, you'll receive a duplicate event. Without idempotency, you'll generate thumbnails twice, scan the file twice, or insert duplicate records into your database.

An idempotent handler produces the same result regardless of how many times it runs for the same input. For file processing, this means using the object's ETag or a content hash as a deduplication key.

Implementation pattern

Store a record of processed events in DynamoDB with the bucket name, object key, and ETag as the composite key. Before processing, check if this combination already exists. If it does, return early.

bash

const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

const TABLE = 'S3ProcessedEvents';

exports.handler = async (event) => {
  const bucket = event.Records[0].s3.bucket.name;
  const key = decodeURIComponent(event.Records[0].s3.object.key);
  const eTag = event.Records[0].s3.object.eTag;

  const id = `${bucket}#${key}#${eTag}`;

  // Check if already processed
  const existing = await dynamodb.get({
    TableName: TABLE,
    Key: { id }
  }).promise();

  if (existing.Item) {
    console.log(`Duplicate event for ${key}, skipping`);
    return { statusCode: 200, message: 'skipped' };
  }

  // Process file
  console.log(`Processing ${key}`);
  // ... thumbnail generation, scanning, etc ...

  // Mark as processed
  await dynamodb.put({
    TableName: TABLE,
    Item: {
      id,
      processedAt: new Date().toISOString(),
      ttl: Math.floor(Date.now() / 1000) + (24 * 60 * 60) // 24h TTL
    }
  }).promise();

  return { statusCode: 200, message: 'processed' };
};

The TTL attribute tells DynamoDB to automatically delete old records, keeping storage costs low. A 24-hour TTL covers most duplicate scenarios while being safe to extend if you need longer deduplication windows.

Stateless idempotency with output naming

For some workloads, you can achieve idempotency without external state. When generating thumbnails, always write the output to the same key. If the same file is processed twice, the second write simply overwrites the first with identical data. This only works if your processing is deterministic (same input, identical output).

This approach fails if you're performing state mutations like incrementing a counter or sending a unique notification. Stick with explicit deduplication for those cases.

Common mistakes

  • Forgetting to configure IAM permissions: S3 needs permission to invoke your Lambda or write to your SQS queue. The resource policy on the destination must explicitly allow the principal s3.amazonaws.com.
  • Using overly broad filters: A prefix filter of / means every object in the bucket triggers an event. This can lead to massive Lambda bills. Be specific about which prefixes and suffixes you actually need to process.
  • Ignoring duplicates in production: Assuming you'll never receive duplicate events is a common path to production incidents. Implement idempotency from the start, not as an afterthought.
  • Not setting Lambda timeout appropriately: If your handler downloads large files, 3 seconds isn't enough. Calculate realistic timeout values based on file size and network conditions. We typically allocate 30–60 seconds for file processing workloads.
  • Treating event notifications as transactional: They're not. If you need guaranteed, ordered processing, use SQS FIFO queues instead, though this comes with throughput tradeoffs.
  • Logging sensitive data from the event: S3 events can contain file paths or metadata that might be confidential. Be selective about what you log to CloudWatch.

Key takeaways

  • S3 event notifications trigger Lambda, SQS, or EventBridge instantly when objects are created, deleted, or restored—no polling required.
  • Choose Lambda for lightweight processing, SQS for high volume or when you need retry logic, and EventBridge for complex filtering.
  • Use prefix and suffix filters to avoid invoking handlers for objects you don't care about; filtering at the S3 layer is cheaper than filtering in code.
  • Make your handlers idempotent by tracking processed events in DynamoDB or by using deterministic output naming; assume duplicates will occur in production.
  • Real-world use cases like thumbnail generation and file scanning follow the same pattern: fetch the object, transform it, store the result, and handle errors gracefully.
  • Common pitfalls include missing IAM permissions, overly permissive filters, and ignoring duplicate events—address all three in any production setup.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles