DEV Community

Cover image for SQS and EventBridge with Lambda: Decouple a Guestbook
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

SQS and EventBridge with Lambda: Decouple a Guestbook

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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),
    reportBatchItemFailures: true,
  }),
);

new cdk.CfnOutput(this, 'QueueUrl', { value: moderationQueue.queueUrl });
new cdk.CfnOutput(this, 'DlqUrl', { value: moderationDlq.queueUrl });
new cdk.CfnOutput(this, 'DlqArn', { value: moderationDlq.queueArn });
Enter fullscreen mode Exit fullscreen mode

What happens here:

  • EventBus creates a dedicated bus. Your account’s default bus also receives events from AWS services; a bus of your own keeps your business events separate from those.
  • addEnvironment passes the bus name to the existing function, and grantPutEventsTo allows it events:PutEvents on this bus only.
  • The queue’s visibility timeout follows the recommendation in the Lambda documentation: at least six times the function timeout plus the batch window. With 10 seconds and a 5-second window, that is 65 seconds, which I round up to 90.
  • maxReceiveCount: 5 allows five delivery attempts before SQS moves a message to the DLQ. For Lambda, AWS recommends at least five. The DLQ keeps messages for 14 days, longer than the four days of the main queue. This matters because for standard queues the age of a message is not reset when it moves to the DLQ.
  • enforceSSL rejects unencrypted access to the queues. One line that costs nothing.
  • The first rule filters on source and detail-type and has the queue as its target. The CDK creates the required queue policy that allows EventBridge to deliver.
  • The second rule filters on the source only and writes every guestbook event to the log group /aws/events/gaestebuch. For the required resource policy, the CDK creates a small helper function. With installLatestAwsSdk: false it does not download a fresh SDK during deployment, which saves time and a warning.
  • SqsEventSource connects queue and function: up to 10 messages per invocation, at most 5 seconds of batching time and reportBatchItemFailures, so that Lambda actually evaluates the list from step 3.

Once again, each function gets only what it needs: moderation may call UpdateItem and nothing else. It cannot read, delete or scan.

Step 5: Write and run the tests

First a small chore. The tests from part 3 do not know about the bus yet. Without an adjustment, the handler would ask for BUS and try real calls to EventBridge. So add these lines to test/eintraege.test.ts:

// test/eintraege.test.ts (changes)
+import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';

 const ddb = mockClient(DynamoDBDocumentClient);
+const eb = mockClient(EventBridgeClient);

 beforeEach(() => {
   ddb.reset();
+  eb.reset();
+  eb.on(PutEventsCommand).resolves({ FailedEntryCount: 0 });
   process.env.TABELLE = 'test-tabelle';
+  process.env.BUS = 'test-bus';
 });
Enter fullscreen mode Exit fullscreen mode

The moderation tests check the behaviour per message, above all the partial failure:

// test/moderation.test.ts
import type { SQSEvent, SQSRecord } from 'aws-lambda';
import { mockClient } from 'aws-sdk-client-mock';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { bewerte, handler } from '../lambda/moderation';

const ddb = mockClient(DynamoDBDocumentClient);

beforeEach(() => {
  ddb.reset();
  process.env.TABELLE = 'test-tabelle';
});

// An SQS message whose body is an EventBridge event
const nachricht = (messageId: string, text: string): SQSRecord =>
  ({
    messageId,
    body: JSON.stringify({
      'detail-type': 'Entry created',
      source: 'gaestebuch',
      detail: { buchId: 'sommerfest', eintragId: `2026-10-25T10:00:00.000Z#${messageId}`, text },
    }),
  }) as unknown as SQSRecord;

const batch = (...records: SQSRecord[]): SQSEvent => ({ Records: records });

test('bewerte detects links and spam words', () => {
  expect(bewerte('Thanks for coming!')).toBe('ok');
  expect(bewerte('Gratis prizes at https://example.com')).toBe('pruefen');
});

test('every message leads to a conditional UpdateItem', async () => {
  ddb.on(UpdateCommand).resolves({});
  const antwort = await handler(batch(nachricht('a1', 'Lovely party'), nachricht('b2', 'Casino!')));
  expect(antwort.batchItemFailures).toEqual([]);
  const aufrufe = ddb.commandCalls(UpdateCommand).map((c) => c.args[0].input);
  expect(aufrufe).toHaveLength(2);
  expect(aufrufe[0].ConditionExpression).toBe('attribute_exists(eintragId)');
  expect(aufrufe[0].ExpressionAttributeValues?.[':m']).toBe('ok');
  expect(aufrufe[1].ExpressionAttributeValues?.[':m']).toBe('pruefen');
});

test('only the failed message goes back to the queue', async () => {
  ddb
    .on(UpdateCommand)
    .resolvesOnce({})
    .rejectsOnce(new Error('Throttling'));
  const antwort = await handler(batch(nachricht('a1', 'Hello'), nachricht('b2', 'Hello')));
  expect(antwort.batchItemFailures).toEqual([{ itemIdentifier: 'b2' }]);
});

test('deleted entry counts as done', async () => {
  ddb.on(UpdateCommand).rejects(
    new ConditionalCheckFailedException({ message: 'missing', $metadata: {} }),
  );
  const antwort = await handler(batch(nachricht('a1', 'Hello')));
  expect(antwort.batchItemFailures).toEqual([]);
});

test('broken JSON ends up in batchItemFailures', async () => {
  const kaputt = { messageId: 'x9', body: '{not json' } as unknown as SQSRecord;
  const antwort = await handler(batch(kaputt));
  expect(antwort.batchItemFailures).toEqual([{ itemIdentifier: 'x9' }]);
  expect(ddb.commandCalls(UpdateCommand)).toHaveLength(0);
});
Enter fullscreen mode Exit fullscreen mode

The next tests make sure the event is really sent and that a rejected event does not undo the entry:

// test/eintraege-events.test.ts
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import { mockClient } from 'aws-sdk-client-mock';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { handler } from '../lambda/eintraege';

const ddb = mockClient(DynamoDBDocumentClient);
const eb = mockClient(EventBridgeClient);

beforeEach(() => {
  ddb.reset();
  eb.reset();
  process.env.TABELLE = 'test-tabelle';
  process.env.BUS = 'test-bus';
  jest.spyOn(console, 'error').mockImplementation(() => undefined);
  jest.spyOn(console, 'log').mockImplementation(() => undefined);
});

const post = (body: unknown): APIGatewayProxyEventV2 =>
  ({
    version: '2.0',
    routeKey: 'POST /buecher/{buchId}/eintraege',
    pathParameters: { buchId: 'sommerfest' },
    headers: {},
    isBase64Encoded: false,
    requestContext: { requestId: 'req-1' },
    body: JSON.stringify(body),
  }) as unknown as APIGatewayProxyEventV2;

test('after saving, an event goes to the bus', async () => {
  ddb.on(PutCommand).resolves({});
  eb.on(PutEventsCommand).resolves({ FailedEntryCount: 0, Entries: [{ EventId: 'e-1' }] });
  const antwort = await handler(post({ name: 'Sascha', text: 'Great party!' }));
  expect(antwort.statusCode).toBe(201);
  const eingabe = eb.commandCalls(PutEventsCommand)[0].args[0].input;
  const eintrag = eingabe.Entries?.[0];
  expect(eintrag?.EventBusName).toBe('test-bus');
  expect(eintrag?.Source).toBe('gaestebuch');
  expect(eintrag?.DetailType).toBe('Entry created');
  expect(JSON.parse(eintrag?.Detail ?? '{}').text).toBe('Great party!');
});

test('invalid entry creates no event', async () => {
  const antwort = await handler(post({ name: 'Sascha' }));
  expect(antwort.statusCode).toBe(400);
  expect(eb.commandCalls(PutEventsCommand)).toHaveLength(0);
});

test('rejected event is logged, the entry stays stored', async () => {
  ddb.on(PutCommand).resolves({});
  eb.on(PutEventsCommand).resolves({
    FailedEntryCount: 1,
    Entries: [{ ErrorCode: 'InternalFailure', ErrorMessage: 'broken' }],
  });
  const antwort = await handler(post({ name: 'A', text: 'B' }));
  expect(antwort.statusCode).toBe(201);
  expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Event rejected'));
});
Enter fullscreen mode Exit fullscreen mode

The stack test pins down the wiring. It complains as soon as someone changes the rule’s pattern, removes the DLQ or switches off partial failures:

// test/entkoppeln-stack.test.ts
import * as cdk from 'aws-cdk-lib/core';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { HalloServerlessStack } from '../lib/hallo-serverless-stack';

const template = Template.fromStack(
  new HalloServerlessStack(new cdk.App(), 'TestStack'),
);

test('rule routes "Entry created" to the queue', () => {
  template.hasResourceProperties('AWS::Events::Rule', {
    EventPattern: { source: ['gaestebuch'], 'detail-type': ['Entry created'] },
    Targets: Match.arrayWith([
      Match.objectLike({ Arn: { 'Fn::GetAtt': [Match.stringLikeRegexp('ModerationQueue'), 'Arn'] } }),
    ]),
  });
});

test('queue has a DLQ after 5 attempts and enough visibility timeout', () => {
  template.hasResourceProperties('AWS::SQS::Queue', {
    VisibilityTimeout: 90,
    RedrivePolicy: Match.objectLike({ maxReceiveCount: 5 }),
  });
});

test('event source mapping reports partial failures', () => {
  template.hasResourceProperties('AWS::Lambda::EventSourceMapping', {
    BatchSize: 10,
    MaximumBatchingWindowInSeconds: 5,
    FunctionResponseTypes: ['ReportBatchItemFailures'],
  });
});

test('moderation may only UpdateItem on the table', () => {
  template.hasResourceProperties('AWS::IAM::Policy', {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({ Action: 'dynamodb:UpdateItem', Effect: 'Allow' }),
      ]),
    },
  });
});

test('entries function may put events on the bus', () => {
  template.hasResourceProperties('AWS::IAM::Policy', {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({ Action: 'events:PutEvents', Effect: 'Allow' }),
      ]),
    },
  });
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx jest
Enter fullscreen mode Exit fullscreen mode

Expected result: the three new test files are green, 13 tests in total, plus all tests from the first three parts. If the visibility timeout test fails, you probably changed the value without redoing the calculation from step 4.

Step 6: Deploy

npx cdk diff
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

cdk diff shows you a bus, two rules, two queues with their policies, a new function with its event source mapping, two log groups and the helper function for the log policy. For the existing function, only the environment variable and the permissions change. After you confirm, the new outputs appear in the terminal (values shortened and illustrative):

✅  HalloServerlessStack

Outputs:
HalloServerlessStack.ApiUrl = https://a1b2c3d4e5.execute-api.eu-central-1.amazonaws.com/
HalloServerlessStack.DlqArn = arn:aws:sqs:eu-central-1:123456789012:HalloServerlessStack-ModerationDlq...
HalloServerlessStack.DlqUrl = https://sqs.eu-central-1.amazonaws.com/123456789012/HalloServerlessStack-ModerationDlq...
HalloServerlessStack.QueueUrl = https://sqs.eu-central-1.amazonaws.com/123456789012/HalloServerlessStack-ModerationQueue...
HalloServerlessStack.TabellenName = HalloServerlessStack-GaestebuchTabelle...
Enter fullscreen mode Exit fullscreen mode

Step 7: Follow an entry on its way

Put the outputs into variables and write two entries, a harmless one and one with a link:

ausgabe() {
  aws cloudformation describe-stacks --stack-name HalloServerlessStack \
    --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text
}
API_URL=$(ausgabe ApiUrl)
TABELLE=$(ausgabe TabellenName)

curl -s -X POST "${API_URL}buecher/sommerfest/eintraege" \
  -H 'content-type: application/json' \
  -d '{"name":"Sascha","text":"Thanks for coming!"}'

curl -s -X POST "${API_URL}buecher/sommerfest/eintraege" \
  -H 'content-type: application/json' \
  -d '{"name":"Bot","text":"Gratis tickets at https://example.com"}'
Enter fullscreen mode Exit fullscreen mode

Both calls respond with 201 right away; moderation runs in the background. The log group of the second rule shows you what happened on the bus:

aws logs tail /aws/events/gaestebuch --since 10m
Enter fullscreen mode Exit fullscreen mode

There you will find a complete event for every entry, like the example above. After a few seconds, you can also see the moderation result directly in the table:

aws dynamodb query --table-name "$TABELLE" \
  --key-condition-expression "buchId = :b" \
  --expression-attribute-values '{":b":{"S":"sommerfest"}}' \
  --projection-expression "eintragId, #t, moderation" \
  --expression-attribute-names '{"#t":"text"}' \
  --no-scan-index-forward --max-items 2
Enter fullscreen mode Exit fullscreen mode

The entry with the link carries "moderation": {"S": "pruefen"} (German for “check”), the other one "ok". By the way, the #t is needed because text is a reserved word in DynamoDB and cannot appear directly in expressions.

Step 8: Try out the dead-letter queue

A DLQ you have never seen will not help you much in an emergency. So send a broken message straight into the moderation queue on purpose:

QUEUE_URL=$(ausgabe QueueUrl)
DLQ_URL=$(ausgabe DlqUrl)

aws sqs send-message --queue-url "$QUEUE_URL" --message-body 'not json'

# About 8 minutes later (5 attempts with a 90-second timeout each):
aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
  --attribute-names ApproximateNumberOfMessages
Enter fullscreen mode Exit fullscreen mode

The function fails at JSON.parse on every attempt and reports the message as a partial failure; after the fifth receive, SQS moves it to the DLQ. The counter then shows "1". Use aws sqs receive-message --queue-url "$DLQ_URL" to look at the content.

With real failures, the process afterwards looks like this: find the cause, fix the bug, deploy and send the messages back to the main queue. There is a dedicated API for that, which you call via the CLI or in the console under “Start DLQ redrive”:

aws sqs start-message-move-task --source-arn "$(ausgabe DlqArn)"

# Our test message cannot be saved, so instead:
aws sqs purge-queue --queue-url "$DLQ_URL"
Enter fullscreen mode Exit fullscreen mode

Without a destination ARN, SQS sends the messages back to the queue they came from. For the test message that would be pointless, as it would only take the same path again. So you empty the DLQ with purge-queue here.

Common mistakes with SQS and EventBridge

Events never arrive in the queue

Most of the time, the event pattern simply does not match. Patterns compare exactly and are case-sensitive, so “Entry Created” does not match “Entry created”. The second classic: the function sends without EventBusName and therefore lands on the default bus, where your rule is not listening. And third, the rejected entries from step 2, which you only find in the log. You can check whether a pattern matches without deploying, using aws events test-event-pattern and a sample event.

Deployment fails at the event source mapping

When creating the connection, Lambda checks that the function timeout is no longer than the queue’s visibility timeout. If you later raise the function timeout to two minutes and forget the queue, the deployment fails. Simply redo the formula from step 4 every time.

One failure retries the whole batch

If reportBatchItemFailures is missing in the stack, Lambda ignores the returned list, and a single failure sends all ten messages back to the queue. The same happens when the function throws an exception instead of catching it per message. I explained how this works in detail in SQS partial batch response.

Messages are processed twice

So this is not a bug but the promise of a standard queue: at least once. Your function has to cope with it. For moderation that is easy, because it writes the same result for the same input. If you send emails or charge money instead, you need protection against duplicates. Idempotency in AWS Lambda shows what that looks like.

Events get lost between saving and sending

Our function first writes to DynamoDB and then sends the event. If it crashes exactly in between, or if EventBridge rejects the event, the entry is stored but nobody hears about it. This is the well-known dual-write problem: two systems, no shared transaction. The SDK does retry transient errors automatically, but that is not a guarantee.

If you need one, let the database produce the event. DynamoDB Streams reliably deliver every change to the table, and EventBridge Pipes can route them to a bus without any code of your own. I have already shown how streams work and how to replay them from a point in time, using AT_TIMESTAMP as the example.

The dead-letter queue silently fills up

A DLQ does not speak up on its own. Without an alarm, you only notice days later that messages are sitting there, and once the retention period expires, they are gone. At least one CloudWatch alarm on the DLQ’s ApproximateNumberOfMessagesVisible metric therefore belongs in every serious project. That is exactly where part 5 starts.

What do SQS and EventBridge cost?

For a classic custom bus, the pricing page lists 1.00 US dollar per million published events, with every started 64 KB chunk counting as one event. There is no free tier for custom events. In the pricing page’s example, delivery to a target such as SQS in the same account costs nothing extra.

For SQS, the pricing page says the first million requests per month are free. After that, a million requests to standard queues cost 0.40 US dollars (as listed for US East), also billed in 64 KB chunks. Every API call counts as a request: delivery by EventBridge, receiving and deleting by Lambda. Lambda combines receiving and deleting for whole batches. Keep in mind that Lambda keeps polling the queue with long polling, and empty polls count as requests too.

An example with 100,000 new entries per month: the events cost about 10 cents. At this volume, the SQS requests stay within the free tier, as long as you are not already using it up elsewhere. On top of that come the moderation function’s invocations, storing the event logs in CloudWatch and one write unit in DynamoDB per update. Overall, at this size you stay in the range of a few cents to a few euros per month. Check the prices for your region; Frankfurt is often slightly more expensive.

My take

EventBridge in front of SQS in front of Lambda is my default pattern as soon as more than one consumer should react to an event. Every consumer gets its own queue with its own DLQ. That costs a few more lines in the stack, but every part can be throttled, paused and repaired on its own. Lambda as a direct target of a rule is fine for non-critical little things. For anything that hurts when it gets lost, I want a queue in between that I can inspect and replay.

Where I push back is the reflex to turn everything into events now. If the caller needs the result immediately, for example the check whether a name is too long, the call stays synchronous. Events pay off where the result can wait and several parties should react independently of each other. I described why this way of thinking is more about mindset than technology in Event-driven thinking.

One more note on our own solution: sending directly from the API function is the simplest way in, not the most robust one. For a guestbook it is perfectly fine, as long as you keep an eye on the error lines. For orders or payments, I would work with streams or an outbox pattern from the start. I show what that looks like in practice in outbox pattern with DynamoDB Streams and EventBridge Pipes.

FAQ

What is the difference between SQS and EventBridge?

SQS is a queue: messages wait until a consumer picks them up. EventBridge is a router: a bus compares events against rules and delivers them to matching targets. You often combine both, with the bus distributing and one queue per consumer buffering.

Why not use Lambda directly as the target of the EventBridge rule?

You can, and for simple cases it is fine. EventBridge then invokes the function asynchronously. With a queue in between, however, you get batches, partial failures and a DLQ with redrive, and you can throttle or pause processing deliberately.

Do I need a FIFO queue?

Only if order matters for your domain or duplicates must be ruled out at queue level. According to the pricing page, FIFO queues cost more per request, and they have tighter throughput limits. For a guestbook, a standard queue with idempotent processing is enough.

How long do messages stay in an SQS queue?

Four days by default, configurable from one minute to 14 days. After that, SQS deletes them, whether anyone processed them or not. That is why you should always give the DLQ a longer retention than the main queue.

Your next step

Build a second consumer: a rule that routes “Entry created” into a new queue, and a function that counts the entries per guestbook. Write the tests first and think about what happens when the same message arrives twice. A simple ADD anzahl :eins then counts double. How would you prevent that?

I am curious: where do you draw the line between a synchronous call and an event in your projects? Feel free to share it in the comments.

Continue the series: Go to the “Serverless from scratch” overview. Previous part: DynamoDB with Lambda and CDK: Build a Guestbook API. Next part: Monitoring Lambda: Alarms, Dashboard and Budget with CDK, including an alarm for the DLQ.

If several consumers need the same event, each with its own queue, I show the pattern in the article Fan-out with SNS and SQS.

Sources

This post was created with AI assistance.


👉 Next step: Need a schedule in EventBridge? My EventBridge cron helper builds and explains the expression for you.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)