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.

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.
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.
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.
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.
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.
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.

Run in your terminal to get started locally.
// 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
}
// 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.
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.
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.
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.
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.
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.
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.