Nov 13, 20268 min read

Pub/Sub Push vs Pull Subscriptions: Which One to Use on Google Cloud

How Google Cloud Pub/Sub push and pull subscriptions differ on endpoints, flow control, throughput, exactly-once delivery and cost on Cloud Run, with Go code for both, and how Encore picks the subscription type for you.

Illustration for Pub/Sub Push vs Pull Subscriptions: Which One to Use on Google Cloud

Pull suits workers that run all the time and need to control their own throughput, and push suits request-driven services such as Cloud Run that should scale with the message rate and to zero between messages. Beyond the hosting model, the choice between Google Cloud Pub/Sub push and pull decides where flow control lives, whether exactly-once delivery is available, which part of the code acknowledges a message, and what a subscriber costs while the topic is quiet.

This comparison runs one subscriber, a "your order has shipped" email, both ways in Go, then shows the same subscriber in Encore, where the subscription type follows from where the service runs.

Push vs pull at a glance

PullPush
Who starts deliveryYour subscriber calls Pull or holds a StreamingPull streamPub/Sub POSTs each message to your HTTPS endpoint
EndpointNone, the subscriber only needs credentials for the Pub/Sub APIA public HTTPS endpoint with a non-self-signed certificate
AcknowledgmentAck or Nack on the message in codeHTTP status: 102, 200, 201, 202 or 204 acks, anything else nacks
Flow controlThe client limits outstanding messages and extends ack deadlinesPub/Sub runs a slow-start push window on the server
ThroughputBatched delivery and acks, the choice for GBs per secondOne message per request, with a cap on outstanding messages
Exactly-once deliverySupportedNot supported
Long processingThe client library keeps extending the deadlineBounded by the subscription's ack deadline, which cannot be changed per message
FitsGKE and VM workers, batch consumers, very high volumeCloud Run, Cloud Run functions, App Engine, webhooks for several topics

The rows come from Google's subscription type guide, the push subscription docs and the exactly-once delivery docs. A third type, export subscriptions, writes messages straight into BigQuery or Cloud Storage with no subscriber code at all.

Pull subscriptions

A pull subscriber holds a StreamingPull connection open and decides how much work it takes on. The Go client library handles the stream, the batching and the ack deadline extensions:

package main import ( "context" "log" "os" "cloud.google.com/go/pubsub/v2" ) func main() { ctx := context.Background() client, err := pubsub.NewClient(ctx, os.Getenv("GOOGLE_CLOUD_PROJECT")) if err != nil { log.Fatal(err) } defer client.Close() sub := client.Subscriber("shipment-events-notify") // Flow control lives in the subscriber: at most 50 messages in // memory, and the library extends ack deadlines while they run. sub.ReceiveSettings.MaxOutstandingMessages = 50 // Receive blocks and keeps a StreamingPull connection open, so this // process has to run continuously. err = sub.Receive(ctx, func(ctx context.Context, m *pubsub.Message) { if err := notifyCustomer(ctx, m.Data); err != nil { m.Nack() // redelivered after the retry policy's backoff return } m.Ack() }) if err != nil { log.Fatal(err) } } func notifyCustomer(ctx context.Context, data []byte) error { return nil }

MaxOutstandingMessages is the backpressure: a slow email provider means fewer messages in flight, not a growing pile of timed-out requests. Several replicas of the same process share the subscription, and each receives a subset of the messages.

Receive only works while the program runs, so the subscriber is a long-lived deployment with its own scaling, health checks and shutdown handling. Exactly-once delivery is a pull-only feature, which matters when duplicate processing is expensive and an idempotency key is hard to add.

Push subscriptions

With push, Pub/Sub turns each message into an HTTPS POST, and the endpoint's status code is the acknowledgment. With authentication configured, Pub/Sub signs a JWT for a service account you choose, and the endpoint should check both the audience and the email claim, as the push authentication docs describe:

package main import ( "context" "encoding/json" "log" "net/http" "os" "strings" "google.golang.org/api/idtoken" ) // The wrapped body Pub/Sub POSTs to a push endpoint. type pushRequest struct { Message struct { Data []byte `json:"data"` // base64 in JSON, decoded by encoding/json Attributes map[string]string `json:"attributes"` MessageID string `json:"messageId"` } `json:"message"` Subscription string `json:"subscription"` } func main() { audience := os.Getenv("PUSH_AUDIENCE") // the audience set on the subscription pushSA := os.Getenv("PUSH_SERVICE_ACCOUNT") http.HandleFunc("POST /pubsub/shipments", func(w http.ResponseWriter, r *http.Request) { // Check the JWT Pub/Sub signs with the push auth service account. token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") payload, err := idtoken.Validate(r.Context(), token, audience) if err != nil || payload.Claims["email"] != pushSA { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req pushRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // A malformed message will never succeed: ack it so it is not retried. w.WriteHeader(http.StatusNoContent) return } if err := notifyCustomer(r.Context(), req.Message.Data); err != nil { // Any non-success status is a nack and feeds push backoff. http.Error(w, "retry", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusNoContent) // 2xx acks the message }) port := os.Getenv("PORT") if port == "" { port = "8080" } log.Fatal(http.ListenAndServe(":"+port, nil)) } func notifyCustomer(ctx context.Context, data []byte) error { return nil }

With push, flow control moves to Google: Pub/Sub starts with a single-digit push window and grows it while the endpoint acknowledges over 99% of messages with under a second of latency. Above 3,000 outstanding messages per region the window grows linearly, and it drops back to that floor when latency or the ack rate slips.

Unlike pull, nacks and expired deadlines on a push subscription trigger push backoff, which pauses delivery for the whole subscription for 100 milliseconds up to 60 seconds, so one bad message type can slow every message behind it. Acknowledge messages that can never succeed, as the handler does with malformed JSON, and only return an error for failures worth retrying.

Push vs pull on Cloud Run

Cloud Run's default request-based billing only charges while an instance handles a request, starts or shuts down, per the billing settings docs. A push subscription fits that model, since each message is a request and an idle topic costs nothing.

A pull subscriber does the opposite of what request-based billing expects. It needs instance-based billing, so the CPU is available outside requests, and a minimum instance count of at least one, so that a process exists to hold the stream. That is a fixed monthly cost per subscriber, which only makes sense when the volume is high or the processing needs client-side flow control.

How does Encore handle Pub/Sub subscriptions?

Encore

Encore brings infrastructure from code to TypeScript and Go backends, where a topic and its subscriptions are declared in the services that publish and consume them, and when the app deploys to your own GCP account, Encore provisions the Pub/Sub topic, the subscription and a dead-letter topic, with IAM for each service, as the GCP infrastructure docs list. On Cloud Run, a subscription on an at-least-once topic is configured as a push subscription with Pub/Sub's adaptive concurrency, as the SubscriptionConfig reference notes.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

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 shop --example=hello-world$ cd shop && mkdir shipping notify

Publish shipment events

// shipping/shipping.go package shipping import ( "context" "time" "encore.dev/beta/errs" "encore.dev/pubsub" ) type ShipmentEvent struct { OrderID string `json:"order_id"` Carrier string `json:"carrier"` Tracking string `json:"tracking"` ShippedAt time.Time `json:"shipped_at"` } // Pub/Sub on GCP, SNS and SQS on AWS, in-memory with `encore run`. var ShipmentEvents = pubsub.NewTopic[*ShipmentEvent]("shipment-events", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type MarkShippedParams struct { Carrier string `json:"carrier"` Tracking string `json:"tracking"` } // MarkShipped records the shipment and publishes the event. // //encore:api private method=POST path=/orders/:orderID/shipped func MarkShipped(ctx context.Context, orderID string, p *MarkShippedParams) error { if p.Carrier == "" || p.Tracking == "" { return &errs.Error{Code: errs.InvalidArgument, Message: "carrier and tracking are required"} } _, err := ShipmentEvents.Publish(ctx, &ShipmentEvent{ OrderID: orderID, Carrier: p.Carrier, Tracking: p.Tracking, ShippedAt: time.Now().UTC(), }) return err }

Subscribe in another service

// notify/notify.go package notify import ( "context" "time" "encore.app/shipping" "encore.dev/pubsub" "encore.dev/rlog" ) // One subscription, no endpoint to expose and no pull loop to run. // On Cloud Run, Encore sets it up as a push subscription. var _ = pubsub.NewSubscription(shipping.ShipmentEvents, "notify-customer", pubsub.SubscriptionConfig[*shipping.ShipmentEvent]{ Handler: sendShippedEmail, AckDeadline: 60 * time.Second, RetryPolicy: &pubsub.RetryPolicy{ MinBackoff: 10 * time.Second, MaxBackoff: 10 * time.Minute, MaxRetries: 20, // then the message goes to the dead-letter topic }, }, ) // Returning an error nacks the message; returning nil acks it. func sendShippedEmail(ctx context.Context, ev *shipping.ShipmentEvent) error { rlog.Info("sending shipped email", "order", ev.OrderID, "carrier", ev.Carrier) return nil // call your email provider here }

The handler is the same function whichever way Pub/Sub delivers the message, and AckDeadline and RetryPolicy map onto the subscription's settings. The Pub/Sub docs cover delivery guarantees, ordering and the full set of subscription options.

encore run runs the topic in memory, so curl -X POST -d '{"carrier":"DHL","tracking":"JD0146"}' localhost:4000/orders/ord_1001/shipped publishes an event, the notify service logs the email, and the local development dashboard traces the request and the handler run. A request without a tracking number returns 400 and publishes nothing.

Deploy to your GCP account

Connect your GCP project as described in Connect your cloud account, create an environment on GCP and run git push encore. The same code runs on AWS with SNS and SQS. When a coding agent later adds a second subscriber, such as a warehouse sync, it adds another pubsub.NewSubscription in its own service, and the next deploy creates that subscription with its own dead-letter topic and permissions.

Best fit: TypeScript and Go teams on Cloud Run who want Pub/Sub subscribers as plain functions, with the subscription type set by where the service runs.

How to choose

Choose pull for long-running workers on GKE or VMs, for throughput in the GBs per second, for processing that runs longer than a fixed ack deadline, or when you need exactly-once delivery. Choose push for Cloud Run and other request-driven platforms, for subscribers that should scale to zero, and for one webhook that serves several topics. Choose Encore when you want to write the handler and leave the subscription type, the dead-letter topic and the IAM to the deploy.

Frequently asked questions

What is the difference between Pub/Sub push and pull?

With a pull subscription, your subscriber connects to Pub/Sub, usually over StreamingPull, and asks for messages at the rate it can handle. With a push subscription, Pub/Sub sends each message as an HTTPS POST to your endpoint and treats a success status as the acknowledgment.

Should I use push or pull on Cloud Run?

Push fits Cloud Run services, since every message arrives as a request and the service can scale to zero between them. A pull subscriber needs a process that keeps a connection open, which on Cloud Run means instance-based billing and at least one instance running all the time.

Does Pub/Sub exactly-once delivery work with push subscriptions?

Exactly-once delivery is limited to pull subscriptions, including StreamingPull subscribers, according to the Pub/Sub docs. Push subscribers should treat delivery as at-least-once and make their handlers idempotent.

Which subscription type does Encore use on Google Cloud?

Encore configures the subscription from pubsub.NewSubscription, and on Cloud Run a subscription on an at-least-once topic is set up as a push subscription with Pub/Sub's adaptive concurrency, and the handler code is the same either way: return nil to acknowledge and an error to retry.

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