Nov 10, 20267 min read

How to Add Pub/Sub to a Gin App

Publish events from Gin handlers and process them in a subscriber with retries, a dead-letter queue and idempotent handling, using Encore or SNS and SQS wired by hand.

Illustration for How to Add Pub/Sub to a Gin App

Gin handles the HTTP side of a Go API well, and the moment a handler needs to send a receipt, call a webhook or kick off a report, the work has to move off the request path. A goroutine looks like the answer until a deploy restarts the container mid-send, and then the choice is between a queue you run yourself and a managed service such as SNS and SQS with its own IAM, dead-letter queue and local stand-in.

This guide adds Pub/Sub to a small Gin orders API, first with Encore, which declares the topic and subscriber in Go and runs them locally and in your own AWS or GCP account, and then with SNS and SQS wired up by hand.

Pub/Sub options for a Gin app at a glance

EncoreSNS + SQS by handGoroutine in the handler
Topic declared inGo code (pubsub.NewTopic)Terraform or the AWS consoleNothing to declare
Local developmentRuns with encore runLocalStack or a shared dev accountRuns in-process
Retries and DLQRetry policy and dead-letter queue per subscriptionRedrive policy you configure per queueNone
IAMScoped per service on deployTopic policy, queue policy and task role you writeNot needed
Survives a restartYesYesNo
ProductionSNS and SQS on AWS, Google Cloud Pub/Sub on GCP, in your accountSNS and SQS you provisionSame process as the API

How do you add Pub/Sub to Gin with Encore?

Encore

Encore works alongside Gin and provisions the infrastructure the code declares in your own AWS or GCP account. The router you already have is mounted in one raw endpoint with a fallback route, the Gin handlers publish to a topic declared a few lines above them, and a subscriber in another service picks the events up.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create orders-api --example=hello-world$ cd orders-api && go get github.com/gin-gonic/gin@v1.12.0$ mkdir -p orders notify/migrations

The orders service declares the topic, builds the Gin router in initService and publishes from the POST /orders handler:

// orders/orders.go package orders import ( "net/http" "encore.dev/pubsub" "github.com/gin-gonic/gin" ) // OrderPlaced is published after an order is accepted. type OrderPlaced struct { OrderID string `json:"order_id"` Email string `json:"email"` Total int64 `json:"total_cents"` } // SNS and SQS on AWS, Google Cloud Pub/Sub on GCP. var OrdersPlaced = pubsub.NewTopic[*OrderPlaced]("orders-placed", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) //encore:service type Service struct { router *gin.Engine } func initService() (*Service, error) { r := gin.New() r.Use(gin.Recovery()) r.POST("/orders", placeOrder) return &Service{router: r}, nil } // Fallback sends every request that no Encore endpoint matches to Gin. // //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } type placeOrderRequest struct { OrderID string `json:"order_id" binding:"required"` Email string `json:"email" binding:"required,email"` Total int64 `json:"total_cents" binding:"required,gt=0"` } func placeOrder(c *gin.Context) { var req placeOrderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } msgID, err := OrdersPlaced.Publish(c.Request.Context(), &OrderPlaced{ OrderID: req.OrderID, Email: req.Email, Total: req.Total, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not publish event"}) return } c.JSON(http.StatusAccepted, gin.H{"order_id": req.OrderID, "message_id": msgID}) }

The notify service subscribes. Delivery is at least once, so the handler claims the order id in a receipts table before it sends anything, and a second delivery of the same event finds the row and returns:

// notify/notify.go package notify import ( "context" "time" "encore.app/orders" "encore.dev/pubsub" "encore.dev/rlog" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("notify", sqldb.DatabaseConfig{ Migrations: "./migrations", }) var _ = pubsub.NewSubscription( orders.OrdersPlaced, "send-receipt", pubsub.SubscriptionConfig[*orders.OrderPlaced]{ Handler: sendReceipt, MaxConcurrency: 20, AckDeadline: 30 * time.Second, RetryPolicy: &pubsub.RetryPolicy{ MinBackoff: 5 * time.Second, MaxBackoff: 5 * time.Minute, MaxRetries: 10, }, }, ) func sendReceipt(ctx context.Context, e *orders.OrderPlaced) error { // Delivery is at least once, so claim the order id before sending. res, err := db.Exec(ctx, ` INSERT INTO receipts (order_id, email) VALUES ($1, $2) ON CONFLICT (order_id) DO NOTHING `, e.OrderID, e.Email) if err != nil { return err // retried with backoff } if res.RowsAffected() == 0 { return nil // receipt already sent for this order } rlog.Info("sending receipt", "order_id", e.OrderID, "total_cents", e.Total) return nil }
-- notify/migrations/1_create_receipts.up.sql CREATE TABLE receipts ( order_id TEXT PRIMARY KEY, email TEXT NOT NULL, sent_at TIMESTAMPTZ NOT NULL DEFAULT now() );

encore run starts the app with the topic, the subscription and a local Postgres for the receipts table, so a POST to localhost:4000/orders publishes an event that the notify handler picks up a moment later, and the local dashboard shows the publish and the subscriber call in the same trace. A handler that returns an error is retried with the backoff in RetryPolicy, and after MaxRetries the message moves to a dead-letter queue for that subscription instead of blocking the ones behind it, as described in the Pub/Sub docs.

On deploy, Encore creates an SNS topic with an SQS queue and dead-letter queue per subscription on AWS, or a Google Cloud Pub/Sub topic and subscription on GCP, and grants the orders service permission to publish and the notify service permission to consume, as listed in the AWS infrastructure docs. A coding agent asked to "also post new orders to Slack" adds a second subscription to the same topic in its own service and can trigger it against the local app before the change is reviewed.

Tests can publish to the topic and assert on the receipts table, since encore test gives each test isolated Postgres and Pub/Sub instances, as the test infrastructure docs describe.

Best fit: Gin services that should keep their router and move receipts, webhooks and other side effects onto a topic that behaves the same on a laptop and in your own AWS or GCP account.

SNS and SQS wired by hand

On AWS the standard fan-out is an SNS topic with one SQS queue per consumer. The Gin handler publishes with the AWS SDK for Go v2, and a separate worker process long-polls its queue:

func (s *server) placeOrder(c *gin.Context) { var req placeOrderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } body, _ := json.Marshal(req) _, err := s.sns.Publish(c.Request.Context(), &sns.PublishInput{ TopicArn: aws.String(os.Getenv("ORDERS_TOPIC_ARN")), Message: aws.String(string(body)), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not publish event"}) return } c.JSON(http.StatusAccepted, gin.H{"order_id": req.OrderID}) }

Around that code sits the infrastructure: the topic, a queue and a dead-letter queue with a redrive policy, an SNS subscription with raw message delivery, a queue policy that lets the topic send to the queue, a task role for the API with sns:Publish on the topic ARN, and a role for the worker with sqs:ReceiveMessage, sqs:DeleteMessage and sqs:ChangeMessageVisibility on the queue. The worker needs its own deployment, a visibility timeout longer than its slowest message, and a loop that deletes each message only after the handler succeeds. AWS documents the pattern in its SNS to SQS fan-out guide.

Locally, the options are LocalStack or pointing the laptop at a shared dev account, and the topic ARN and queue URL differ per environment, so each one lands in config. The result is full control over every queue attribute, at the cost of a second copy of the setup for staging and a local loop that only approximates the deployed one.

Best fit: Teams whose SNS and SQS resources are already managed in Terraform by a platform team and who want the Gin app to only call the SDK.

A goroutine in the handler

The shortest path is go sendReceipt(order) before returning the response, and for best-effort work such as warming a cache it is enough. For anything that must happen it has three gaps: the work is lost when the process exits during a deploy, a failure is logged and then forgotten with no retry, and there is no backlog to look at when the email provider is down for an hour. Gin's own docs also warn that a goroutine must use c.Copy() rather than the original context, since the context is reused after the handler returns.

Best fit: Fire-and-forget work where losing an occasional run costs nothing.

How to choose

Pick Encore when the Gin router should stay and the side effects should run from a topic declared in Go, with retries, a dead-letter queue and publish and consume permissions for each service provisioned in your own AWS or GCP account, and the same flow running under encore run. Pick SNS and SQS by hand when the messaging layer belongs to a platform team and already lives in Terraform. Keep goroutines for work you can afford to drop.

Frequently asked questions

How do I publish an event from a Gin handler?

Create the topic once at package level, then call Publish with c.Request.Context() inside the handler and return 202 once the publish succeeds. The subscriber runs separately, so the HTTP response does not wait for the email, webhook or report the event triggers.

How do I add Pub/Sub to a Gin app with Encore?

Declare the topic with pubsub.NewTopic[*OrderPlaced]("orders-placed", pubsub.TopicConfig{DeliveryGuarantee: pubsub.AtLeastOnce}), call OrdersPlaced.Publish from the Gin handler, and register a handler with pubsub.NewSubscription in another service. encore run delivers the events locally, and on deploy Encore provisions SNS and SQS on AWS or Google Cloud Pub/Sub on GCP in your own account.

Should Gin use goroutines or a queue for background work?

A goroutine started from a handler is lost when the process restarts or the deploy replaces the container, and nothing retries it after a failure. Use a queue or Pub/Sub topic for anything that must happen, such as receipts, webhooks or billing, and keep goroutines for best-effort work like warming a cache.

Why do Pub/Sub subscribers need to be idempotent?

SQS, SNS and Google Cloud Pub/Sub deliver at least once, so a subscriber can see the same message twice after a timeout or a retry. Record the event id or business key in a table with a unique constraint before doing the side effect, and skip the work when the insert finds an existing row.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders