SQS and EventBridge are, for me, the two services that turn a handful of Lambda functions into a Serverless application that shrugs off traffic spikes and outages. So far, our guestbook from part 3 does everything in one go: the function validates the entry, writes it to DynamoDB and responds.

But something is always added: a spam check for new entries, later perhaps an email to the hosts or some statistics. If all of that lives in the function behind the API, every guest waits for every step, and a hanging email service can even make saving fail. So this part separates accepting a request from processing it: the API stores the entry and reports an event, and everything else happens behind it, decoupled and with built-in retries.

In short

The API function stores the entry and puts an event on its own EventBridge bus. A rule routes it into an SQS queue, where a second function processes it with per-message partial failures and a dead-letter queue. The biggest pitfall: PutEvents reports rejected events only in its response instead of throwing.

What you will learn in this article

  • When to use SQS, when EventBridge, and why the pair works so well
  • Sending events with the AWS SDK v3 and spotting rejected entries
  • A bus, rules, a queue and a dead-letter queue in the CDK
  • Partial failures per message instead of retrying whole batches
  • Finding messages in the dead-letter queue and sending them back

Why decouple when it already works?

With a synchronous call, the caller waits until everything is done. Decoupling means that the part accepting a request no longer knows who takes care of it afterwards. It only reports that something happened, in our case “Entry created”. Whoever is interested listens.

That gives you tangible benefits. The guest only waits for the save, not for the moderation. An error in the moderation stays where it happens: the entry is stored, the message waits in the queue and is tried again later. And when statistics come along next month, you write a new rule and a new function without even touching the API function.

Comparison: on the left one function calls everything in sequence, on the right it only saves and an event bus feeds queues

Before, the guest waits for every task. After, the API only saves, and the rest runs decoupled through the bus and queues.

It does come at a price, though. Right after saving, an entry is not moderated yet; the result arrives a few seconds later. This is called “eventual consistency”: the system becomes consistent, just not at the same instant. You also have more parts to keep an eye on. For a guestbook that is a good trade. For a bank transfer whose result the user has to see immediately, probably not.

SQS and EventBridge: who does what?

Both services move messages, yet they solve different problems. Mix them up and you either build a router out of queues or a queue out of rules. Both are possible, and both hurt.

SQS: the queue

Amazon SQS (Simple Queue Service) works like a mailbox. Messages stay in it until a receiver picks them up and deletes them after successful processing. When someone picks a message up, it becomes invisible for a configurable period, the visibility timeout. If the receiver does not delete it within that time, it reappears and is delivered again. So you get retries without writing any code for them.

With Lambda, you do not fetch the messages yourself. An event source mapping does that for you: a part of the Lambda service that polls the queue and invokes your function with a batch of messages. If a message fails too often, SQS moves it to a dead-letter queue (DLQ), a second queue for problem cases. A standard queue delivers every message at least once, occasionally twice, and does not guarantee any order.

EventBridge: the router

Amazon EventBridge is more like a mailroom with sorting rules. You send events to an event bus. Each event is a JSON document with a source (source), a type (detail-type) and the actual content (detail). Rules on the bus compare every event against a pattern, the event pattern, and forward matching events to their targets. According to the quotas page, a rule can have up to five targets, and a bus allows 300 rules by default.

If delivery to a target fails, the documentation says EventBridge retries for up to 24 hours and up to 185 times by default. Anything that still has not arrived after that is dropped, unless you have configured a DLQ for that target. What the bus does not offer is a place where events wait for a slow consumer.

Why I combine both

The EventBridge → SQS → Lambda pattern uses the strengths of both services. The bus decides who gets which event. Each consumer has its own queue that absorbs load spikes, sets the pace and collects problem cases. If moderation is down for an hour, messages pile up in its queue, and all other consumers keep working undisturbed.

Criterion SQS EventBridge
Core idea Queue for one consumer Router with rules for many consumers
Delivery Consumer fetches (pull) Bus delivers (push)
Filtering no content-based filter Event patterns per rule
Retries up to maxReceiveCount, then DLQ up to 24 hours and 185 attempts, optional DLQ
Typical use Buffer work and process it evenly Distribute events to several consumers

SQS and EventBridge compared: the queue buffers for one consumer, the bus distributes to many.

Not sure which service fits your own case? My AWS service finder asks you a few questions and suggests a direction.

The architecture for the guestbook

After this part, an entry travels like this: the familiar function behind POST /buecher/{buchId}/eintraege stores the entry as before and then puts an event of type “Entry created” on the bus GaestebuchBus. Two rules listen. The first routes the event into the ModerationQueue, which the new moderation function reads from. It checks the text and writes the result back into the item. The second rule additionally writes every guestbook event to a log group in CloudWatch Logs. That is your window onto the bus whenever you want to know what is going on there.

🎬 An entry travelling through bus and queue – this animated diagram only runs on my site: watch it on sascha-ms.de

This is what an event looks like when it arrives in the queue later. EventBridge adds the fields around detail itself, while the content of detail comes from your function. Values such as the account and IDs are examples:

{ "version": "0", "id": "7f3c2a1e-0b6d-4c1e-9a55-2d8e4f6a1b90", "detail-type": "Entry created", "source": "gaestebuch", "account": "123456789012", "time": "2026-10-25T14:02:11Z", "region": "eu-central-1", "resources": [], "detail": { "buchId": "sommerfest", "eintragId": "2026-10-25T14:02:10.874Z#3c9d0e1f", "name": "Sascha", "text": "Thanks for coming!", "erstelltAm": "2026-10-25T14:02:10.874Z" } } 

Prerequisites

You need the hallo-serverless project from the first three parts, above all the guestbook API with DynamoDB. Specifically:

  • Node.js 22 or newer and AWS CLI v2 with a configured profile and region
  • The CDK project in TypeScript with esbuild, Jest and aws-sdk-client-mock from part 3
  • The table GaestebuchTabelle and the function EintraegeFunktion in the stack
  • curl in your terminal; on Windows, Git Bash or WSL work well

You do not need to install any new constructs: EventBridge, SQS and the Lambda event sources are already part of aws-cdk-lib. The only new thing is one SDK package for the function code.

SQS and EventBridge step by step

Step 1: Install the SDK package for EventBridge

npm install @aws-sdk/client-eventbridge 

Just like the DynamoDB client from part 3, this package is already included in the Lambda Node.js runtime, so NodejsFunction leaves it out of the bundle. You still need to install it so that TypeScript knows the types and the tests run.

Step 2: Send the event after saving

In lambda/eintraege.ts you add a client for EventBridge, a small function meldeEintrag and two changed spots. The rest of the file stays as it was in part 3.

// lambda/eintraege.ts (excerpt: the changes from part 4) import { EventBridgeClient, PutEventsCommand, } from '@aws-sdk/client-eventbridge'; // Right below the DynamoDB client from part 3 const eb = new EventBridgeClient({}); // Part 4: put the new entry on the guestbook bus as an event export const meldeEintrag = async ( bus: string, eintrag: Eintrag, ): Promise<boolean> => { try { const antwort = await eb.send( new PutEventsCommand({ Entries: [ { EventBusName: bus, Source: 'gaestebuch', DetailType: 'Entry created', Detail: JSON.stringify(eintrag), }, ], }), ); // PutEvents does not throw for rejected entries. // Whether it worked is only visible in FailedEntryCount. if (antwort.FailedEntryCount) { const fehler = antwort.Entries?.[0]; console.error(JSON.stringify({ level: 'error', msg: 'Event rejected', eintragId: eintrag.eintragId, code: fehler?.ErrorCode, message: fehler?.ErrorMessage, })); return false; } return true; } catch (fehler) { console.error(JSON.stringify({ level: 'error', msg: 'Event not sent', eintragId: eintrag.eintragId, error: (fehler as Error).name, })); return false; } }; // neuerEintrag receives the bus name and reports the entry after saving it const neuerEintrag = async ( tabelle: string, bus: string, buchId: string, event: APIGatewayProxyEventV2, ): Promise<APIGatewayProxyStructuredResultV2> => { // ... validation and PutCommand unchanged from part 3 ... // Part 4: the entry is stored. Everything else happens decoupled. await meldeEintrag(bus, eintrag); return json(201, eintrag); }; // In the handler: read the bus name and pass it to neuerEintrag const bus = process.env.BUS; if (!tabelle || !bus) throw new Error('Environment variable TABELLE or BUS is missing'); // ... case 'POST /buecher/{buchId}/eintraege': return neuerEintrag(tabelle, bus, buchId, event); 

The most important line looks harmless: the check for FailedEntryCount. PutEvents responds with HTTP 200 even if EventBridge rejected individual entries, and the SDK does not throw in that case. You only learn whether your event arrived from the response, which contains either an event ID or an error code for every entry you sent, in the same order as your request. Overlook this and you lose events without anything ever turning red.

The second decision is in the return value. If sending fails, the function still responds with 201. After all, the entry is stored, and an error would only make the guest submit it twice. Instead, the function writes an error line to the log, which you will watch with an alarm in part 5. For a guestbook that is acceptable. Where every event counts, you need a different approach, more on that in the common mistakes.

Step 3: Write the moderation function

Create the file lambda/moderation.ts. Lambda hands it a batch of SQS messages; it checks every text and writes the result into the matching item.

// lambda/moderation.ts import type { SQSBatchResponse, SQSEvent, SQSRecord } from 'aws-lambda'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); // Deliberately simple: a real project uses a better check here const VERDAECHTIG = [/https?:\/\//i, /\bcasino\b/i, /\bgratis\b/i]; type GaestebuchEvent = { 'detail-type': string; detail: { buchId: string; eintragId: string; text: string }; }; export const bewerte = (text: string): 'ok' | 'pruefen' => VERDAECHTIG.some((muster) => muster.test(text)) ? 'pruefen' : 'ok'; const verarbeite = async (tabelle: string, record: SQSRecord): Promise<void> => { // The message contains the complete EventBridge event as JSON const event = JSON.parse(record.body) as GaestebuchEvent; const { buchId, eintragId, text } = event.detail; const ergebnis = bewerte(text); try { await db.send( new UpdateCommand({ TableName: tabelle, Key: { buchId, eintragId }, UpdateExpression: 'SET moderation = :m, geprueftAm = :t', // Only touch existing entries, never create new items ConditionExpression: 'attribute_exists(eintragId)', ExpressionAttributeValues: { ':m': ergebnis, ':t': new Date().toISOString(), }, }), ); } catch (fehler) { if ((fehler as Error).name === 'ConditionalCheckFailedException') { // Entry was deleted in the meantime: nothing left to do console.warn(JSON.stringify({ level: 'warn', msg: 'Entry missing', eintragId })); return; } throw fehler; } console.log(JSON.stringify({ level: 'info', msg: 'checked', eintragId, ergebnis })); }; export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => { const tabelle = process.env.TABELLE; if (!tabelle) throw new Error('Environment variable TABELLE is missing'); const batchItemFailures: SQSBatchResponse['batchItemFailures'] = []; for (const record of event.Records) { try { await verarbeite(tabelle, record); } catch (fehler) { console.error(JSON.stringify({ level: 'error', msg: 'Message failed', messageId: record.messageId, error: (fehler as Error).message, })); // Only this message goes back to the queue batchItemFailures.push({ itemIdentifier: record.messageId }); } } return { batchItemFailures }; }; 

A few spots deserve a second look:

  • The body of every SQS message is the complete EventBridge event as JSON text. So the function reads event.detail and not buchId directly.
  • UpdateCommand sets only two attributes and leaves the rest of the item alone. The condition attribute_exists(eintragId) prevents an update from creating a new, half-empty item if the entry has been deleted in the meantime.
  • Every message runs in its own try/catch. If one fails, its messageId ends up in batchItemFailures. Only that message returns to the queue; the others count as done.
  • The function only throws when its configuration is missing. Throwing would mark the whole batch as failed.

The check itself, three regular expressions, is deliberately simple; this is about transport, not spam detection. One property is worth remembering, though: if the function receives the same message twice, it writes the same result twice. It is idempotent, and that is exactly what you need with a queue that delivers “at least once”.

Step 4: Create the bus, rules and queue in the stack

Open lib/hallo-serverless-stack.ts and add the imports at the top:

// lib/hallo-serverless-stack.ts (new imports) import * as events from 'aws-cdk-lib/aws-events'; import * as targets from 'aws-cdk-lib/aws-events-targets'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; 

Insert the following block after the guestbook part from part 3, that is after api.addRoutes for the entries and before the outputs.

// lib/hallo-serverless-stack.ts (excerpt: insert part 4 after the block from part 3) // --- Part 4: decouple with EventBridge and SQS --- // 1. A dedicated event bus for everything around the guestbook const bus = new events.EventBus(this, 'GaestebuchBus'); eintraegeFunktion.addEnvironment('BUS', bus.eventBusName); bus.grantPutEventsTo(eintraegeFunktion); // 2. Queue for moderation, with a dead-letter queue for permanent failures const moderationDlq = new sqs.Queue(this, 'ModerationDlq', { retentionPeriod: cdk.Duration.days(14), enforceSSL: true, }); const moderationQueue = new sqs.Queue(this, 'ModerationQueue', { // at least 6 x function timeout (10 s) + batch window (5 s) visibilityTimeout: cdk.Duration.seconds(90), deadLetterQueue: { queue: moderationDlq, maxReceiveCount: 5 }, enforceSSL: true, }); // 3. Rule: "Entry created" lands in the moderation queue new events.Rule(this, 'EintragErstelltRegel', { eventBus: bus, description: 'New guestbook entries for moderation', eventPattern: { source: ['gaestebuch'], detailType: ['Entry created'] }, targets: [new targets.SqsQueue(moderationQueue)], }); // 4. Second rule: additionally log all guestbook events const eventLogs = new logs.LogGroup(this, 'GaestebuchEventLogs', { logGroupName: '/aws/events/gaestebuch', retention: logs.RetentionDays.ONE_WEEK, removalPolicy: cdk.RemovalPolicy.DESTROY, }); new events.Rule(this, 'AlleEventsRegel', { eventBus: bus, description: 'Log of all guestbook events', eventPattern: { source: ['gaestebuch'] }, // The helper function for the log policy needs no newer SDK targets: [new targets.CloudWatchLogGroup(eventLogs, { installLatestAwsSdk: false })], }); // 5. The moderation function pulls messages from the queue const moderationLogs = new logs.LogGroup(this, 'ModerationFunktionLogs', { retention: logs.RetentionDays.ONE_WEEK, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const moderationFunktion = new NodejsFunction(this, 'ModerationFunktion', { entry: path.join(__dirname, '..', 'lambda', 'moderation.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, memorySize: 256, timeout: cdk.Duration.seconds(10), logGroup: moderationLogs, description: 'Serverless from scratch, part 4: moderation via SQS', environment: { TABELLE: tabelle.tableName }, bundling: { minify: true }, }); tabelle.grant(moderationFunktion, 'dynamodb:UpdateItem'); moderationFunktion.addEventSource( new SqsEventSource(moderationQueue, { batchSize: 10, maxBatchingWindow: cdk.Duration.seconds(5), reportBatchItemFailuresSource:https://dev.to/sascha-ms/sqs-and-eventbridge-with-lambda-decouple-a-guestbook-4l20

Related posts