Cloud Tasks vs Pub/Sub on GCP: when to queue a task and when to publish an event
When a backend on GCP needs to decouple asynchronous work, the same question shows up in design almost every time: does this go through Cloud Tasks or Pub/Sub? Both are managed queues, both decouple the producer from the consumer, and both show up in the docs with "asynchronous" attached just as often. That surface-level similarity is exactly why we've picked the wrong one more than once for a problem that looked interchangeable at first glance.
The difference isn't speed of delivery or cost. It's the delivery model: Cloud Tasks delivers to one specific target, with fine-grained control over rate and per-task retry. Pub/Sub publishes an event that any number of subscribers can receive, each independently of the others. Mixing up those two models doesn't break anything right away, but over time it produces a design that fights the tool instead of leaning on it.
Cloud Tasks: one consumer, rate control, per-task retry
Cloud Tasks was built for a specific case: code that knows exactly who it needs to hand a piece of work to, and needs to control how much of that work gets processed per second. The API looks like this:
from google.cloud import tasks_v2
import json
client = tasks_v2.CloudTasksClient()
parent = client.queue_path("my-project", "us-central1", "email-dispatch")
task = {
"http_request": {
"http_method": tasks_v2.HttpMethod.POST,
"url": "https://api.myapp.com/internal/send-email",
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"user_id": "u_123", "template": "welcome"}).encode(),
}
}
client.create_task(parent=parent, task=task)
Every task has a single HTTP target. The queue has rate settings (max_dispatches_per_second) and concurrency settings (max_concurrent_dispatches) that apply to the whole queue, and each individual task carries its own configurable retry policy, with backoff and a maximum attempt count. If the task fails, Cloud Tasks retries it against that same target until the policy is exhausted; nobody else ever finds out the task existed.
This is a good fit when a piece of work has exactly one owner responsible for running it, and that owner needs protection from a spike in load. The typical case is a rate-limited external API: sending email through a provider that throttles or blocks bursts, syncing against a legacy system that falls over past a certain number of requests per second, or scheduling a task to run at a specific point in the future (Cloud Tasks supports schedule_time natively, which Pub/Sub doesn't offer without building a separate delay mechanism on top).
Pub/Sub: one event, zero or many subscribers
Pub/Sub solves a different problem: when what happened matters to more than one consumer, and none of them should have to know the others exist.
from google.cloud import pubsub_v1
import json
publisher = pubsub_v1.PublisherClient()
topic_path = publisher.topic_path("my-project", "order-created")
data = json.dumps({"order_id": "o_456", "amount": 4500}).encode("utf-8")
publisher.publish(topic_path, data)
Whoever publishes to order-created doesn't know, and doesn't need to know, how many subscribers are listening on that topic. There could be one updating inventory, another firing a push notification, and a third feeding an analytics pipeline, all subscribed to the same topic independently of each other. Adding a fourth subscriber requires no change to the publisher's code, and that's the whole point: the producer is declaring a fact about the domain, not sending an instruction to a specific recipient.
The tradeoff is that Pub/Sub gives you no fine-grained control over delivery rate to a specific consumer. Each subscription has its own delivery flow and its own backlog, and throttling how much load a particular subscriber receives is that subscriber's job — by adjusting how many messages it pulls per second, for instance — not something you configure centrally the way you set max_dispatches_per_second on a Cloud Tasks queue.
Where each one is a bad fit
Using Pub/Sub for what's really a task aimed at a single consumer that needs rate control leads to a recurring problem: to fake that control, you end up building, outside of Pub/Sub, the throttling logic that Cloud Tasks already ships with. We ran into this on a sync flow against a client's ERP that returned a 429 past a handful of requests per second. The first version used a Pub/Sub topic with a single push subscription pointed at the sync endpoint, and to avoid overwhelming the ERP we had to bolt on a Redis-based semaphore inside the endpoint itself that rejected requests above a certain rate and let Pub/Sub retry the rejected messages. It worked, but it meant hand-building, as one more moving part, something a Cloud Tasks queue solves with two configuration values.
The reverse case shows up too: using Cloud Tasks for something that's really an event several systems need to know about. If the original flow was "when an order is created, queue a task that updates inventory" using Cloud Tasks, and later on you also need to notify the user and also feed the analytics pipeline whenever an order is created, the most direct move — queuing three separate tasks from the same code path that creates the order — ties the order-creation code to knowledge of all three consumers. Every new consumer forces a change to the code that publishes, which is exactly what Pub/Sub is designed to avoid.
What didn't work: using Pub/Sub as a stand-in for scheduled tasks
One attempt we ended up reverting was using Pub/Sub for delayed tasks, since Cloud Scheduler can publish to a topic and we already had Pub/Sub infrastructure in the project. The idea was to publish a message with a run_after field and have the subscriber check that field before processing, re-queuing the message if it wasn't time yet. In practice this created a cycle of messages re-sending themselves over and over until the delay was satisfied, inflating the subscription's delivery count and making it hard to tell, from the metrics, how many messages were "real" pending work versus how many were just waiting-retries. Cloud Tasks handles this natively with schedule_time at task creation, no extra mechanism needed. We moved that specific case back to Cloud Tasks and kept Pub/Sub only for the domain events that genuinely have more than one interested party.
The rule we apply
The question we ask before picking one isn't which tool is more modern or which one the rest of the team already uses elsewhere. It's: does this work have a single, known-in-advance destination, and do I need to control how much of it gets processed per second? That's Cloud Tasks. Is this a fact about the domain that consumers who don't even exist yet might care about, and do I want adding a new one to require zero changes to the code that publishes it? That's Pub/Sub. When a flow starts out looking like the first case and later picks up a second or third interested party, that's the moment to migrate it to Pub/Sub — not earlier, just in case.