Skip to content

Instantly share code, notes, and snippets.

@bertolo1988
Last active March 17, 2026 23:46
Show Gist options
  • Select an option

  • Save bertolo1988/5a4cf530e14d2d640bed4dc6100b7349 to your computer and use it in GitHub Desktop.

Select an option

Save bertolo1988/5a4cf530e14d2d640bed4dc6100b7349 to your computer and use it in GitHub Desktop.

How to Replay SQS Events

Sometimes a message fails to process and ends up in a dead-letter queue, or you need to re-send an event to test a fix. Instead of clicking through the AWS console, you can write a small TypeScript script that sends any typed payload straight to an SQS queue.

This article walks through building two pieces:

  1. A producer script that accepts a typed body and sends it to a queue.
  2. A consumer that polls the queue and processes each message by type.

Prerequisites

  • Node.js 18+
  • An AWS account with an SQS queue created

Install the dependencies:

npm init -y
npm install @aws-sdk/client-sqs
npm install -D typescript @types/node tsx

Configuring AWS credentials

The AWS SDK picks up credentials from environment variables. Export these in your terminal before running any of the scripts:

export AWS_ACCESS_KEY_ID=your-access-key-id
export AWS_SECRET_ACCESS_KEY=your-secret-access-key
export AWS_REGION=eu-west-1

Add a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "outDir": "dist",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Defining the message types

Start by defining the shapes your queue understands. Create src/types.ts:

export type OrderCreated = {
  type: "order.created";
  orderId: string;
  amount: number;
};

export type OrderCancelled = {
  type: "order.cancelled";
  orderId: string;
  reason: string;
};

export type SQSEvent = OrderCreated | OrderCancelled;

The type field acts as a discriminator. When you add a new event, you add a new member to the union and both the producer and consumer stay type-safe.

The consumer

Create src/consumer.ts:

import {
  SQSClient,
  ReceiveMessageCommand,
  DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
import type { SQSEvent } from "./types.js";

const client = new SQSClient({});

const QUEUE_URL = process.env.QUEUE_URL;
if (!QUEUE_URL) {
  console.error("QUEUE_URL environment variable is required");
  process.exit(1);
}

function handle(event: SQSEvent): void {
  switch (event.type) {
    case "order.created":
      console.log(`Processing new order ${event.orderId} for $${event.amount}`);
      break;
    case "order.cancelled":
      console.log(`Cancelling order ${event.orderId}: ${event.reason}`);
      break;
    default:
      // If you add a new type to the union but forget to handle it here,
      // TypeScript will flag this line as an error.
      const _exhaustive: never = event;
      console.error("Unknown event type", _exhaustive);
  }
}

async function poll(): Promise<void> {
  console.log("Polling for messages…");

  while (true) {
    const response = await client.send(
      new ReceiveMessageCommand({
        QueueUrl: QUEUE_URL,
        MaxNumberOfMessages: 10,
        WaitTimeSeconds: 20,
      })
    );

    if (!response.Messages || response.Messages.length === 0) {
      continue;
    }

    for (const message of response.Messages) {
      try {
        const event: SQSEvent = JSON.parse(message.Body!);
        handle(event);

        await client.send(
          new DeleteMessageCommand({
            QueueUrl: QUEUE_URL,
            ReceiptHandle: message.ReceiptHandle,
          })
        );
      } catch (err) {
        console.error("Failed to process message", message.MessageId, err);
      }
    }
  }
}

poll();

You can get your AWS_ACCOUNT_ID from AWS console. Now export the QUEUE_URL such as:

QUEUE_URL=https://sqs.eu-west-1.amazonaws.com/{aws-account-id}/my-queue

Run consumer in a terminal with the following command:

  npx tsx src/consumer.ts

The consumer uses long polling (WaitTimeSeconds: 20) so it doesn't burn through API calls while idle. Each message is parsed, routed by its type discriminator, and deleted only after successful processing. If processing throws, the message stays in the queue and becomes visible again after the visibility timeout expires — exactly the retry behaviour you want.

The producer script

Create src/send.ts:

import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import type { SQSEvent } from "./types.js";

const client = new SQSClient({});

const QUEUE_URL = process.env.QUEUE_URL;
if (!QUEUE_URL) {
  console.error("QUEUE_URL environment variable is required");
  process.exit(1);
}

async function send(body: SQSEvent): Promise<void> {
  const command = new SendMessageCommand({
    QueueUrl: QUEUE_URL,
    MessageBody: JSON.stringify(body),
    MessageAttributes: {
      EventType: {
        DataType: "String",
        StringValue: body.type,
      },
    },
  });

  const result = await client.send(command);
  console.log(`Sent [${body.type}] — MessageId: ${result.MessageId}`);
}

// Read the payload from a CLI argument
const raw = process.argv[2];
if (!raw) {
  console.error("Usage: tsx src/send.ts '<json>'");
  console.error(`Example: tsx src/send.ts '{"type":"order.created","orderId":"abc-123","amount":49.99}'`);
  process.exit(1);
}

const body: SQSEvent = JSON.parse(raw);
send(body);

Open a new terminal, export the QUEUE_URL again:

QUEUE_URL=https://sqs.eu-west-1.amazonaws.com/{aws-account-id}/my-queue

Then, run the send script with:

npx tsx src/send.ts '{"type":"order.created","orderId":"abc-123","amount":49.99}'

The script is intentionally minimal. It takes a JSON string from the command line, parses it into the SQSEvent union, and pushes it to the queue. The EventType message attribute is a nice touch — it lets consumers filter messages without parsing the body first.

Wrapping up

The whole setup is three small files and one shared type. When something goes wrong in production, you can replay a specific event in seconds instead of navigating the AWS console or writing one-off Lambda invocations. Because the payload types are shared between the producer and consumer, TypeScript catches shape mismatches at compile time — you will never send a message the consumer does not know how to handle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment