Short answer: for a small SaaS that runs periodic property-management cleanup, start with a polling worker unless you already operate a stable public HTTPS application endpoint. Push delivery is convenient at the edge, but retry and idempotency decide whether either design is safe. The easiest setup is the one that puts the fewest new failure modes between “task due” and “lease removed.”
The concrete job here is easy to picture: every night, find rental listings whose temporary hold has expired, release the hold, and record an audit event. The web request that schedules the work should return quickly. A scheduler publishes a task with a stable cleanup_id; either a public webhook receives it or a private worker polls for it. The worker writes the result, then acknowledges delivery.
That sequence has one awkward gap. A process can commit the release and die before its acknowledgment reaches the queue. The next delivery must be boring: it should observe the same cleanup_id, avoid repeating the side effect, and acknowledge the already-completed task.
Start With the Cleanup Audit Record
Before choosing transport, define the record that proves what happened. For each property cleanup, keep the stable cleanup ID, property ID, lease ID, due time, attempt count, completion time, and outcome. That record is useful in a support conversation and in an evaluation harness; a queue dashboard alone cannot tell you if a lease was released twice.
The audit row comes first.
For a nightly run, the scheduler should create one logical task per expired hold. A duplicate scheduler tick must resolve to the same cleanup ID, not create a second business action with a new random delivery ID. This is the place to put a database uniqueness constraint, before a webhook or worker has a chance to make the problem harder to see.
How should a small SaaS choose public HTTPS webhook delivery or polling for property cleanup?
Choose push subscription delivery when the receiving service is already a durable, public HTTPS application. The queue can call the endpoint, and the application can hand the task to a short-lived execution path. This keeps the deployment shape close to an existing web service and avoids maintaining a separate receive loop.
Choose polling when the consumer is private, local-first, or a real background process. A worker can sit beside a notebook while an evaluation harness exercises delayed jobs, then move to a private production network without exposing a new inbound route. It also owns concurrency, shutdown, and backoff in one place.
The catch is that push moves complexity into ingress. The endpoint needs authentication, request limits, deployment continuity, and a response policy for timeouts. Polling moves complexity into the worker: it needs a supervisor, a lease or visibility timeout, and a deliberate retry loop. Neither transport makes a duplicate impossible.
For the property-cleanup example, I would use a private polling worker if the team does not already have public ingress. A small deployment has enough moving parts already. If the app has a monitored HTTPS boundary and the queue's push contract fits it, push is a sensible choice. Your mileage may vary once regional data rules, traffic bursts, or strict completion latency become more important than setup effort.
Build the Adapter Around a Stable Task Contract
Treat delivery as at-least-once unless the chosen service explicitly documents a stronger contract. A retry can happen after a timeout, after a network disconnect, or after the consumer has finished but before it has acknowledged the message. “The handler returned 200” is not the same as “the database has one durable cleanup result.”
The idempotency key belongs to the business task, not to an individual delivery attempt. For this job, it might be lease-expiry:property-1842:2026-08-10. Store that key with the result and put a uniqueness constraint on it. If the same logical cleanup arrives twice with different delivery IDs, both deliveries still point to one business action.
Here is a small Python example. The queue client is intentionally an interface: the correctness boundary should be testable without tying the application to push or poll transport.
import sqlite3 from dataclasses import dataclass from typing import Protocol @dataclass(frozen=True) class CleanupTask: cleanup_id: str property_id: str lease_id: str class Queue(Protocol): def acknowledge(self, delivery_id: str) -> None: ... def apply_cleanup(db: sqlite3.Connection, task: CleanupTask) -> bool: """Return False when this logical cleanup was already committed.""" db.execute( """ CREATE TABLE IF NOT EXISTS cleanup_effects ( cleanup_id TEXT PRIMARY KEY, property_id TEXT NOT NULL, lease_id TEXT NOT NULL ) """ ) try: with db: db.execute( """ INSERT INTO cleanup_effects(cleanup_id, property_id, lease_id) VALUES (?, ?, ?) """, (task.cleanup_id, task.property_id, task.lease_id), ) db.execute( """ UPDATE leases SET hold_state = 'released' WHERE property_id = ? AND lease_id = ? AND hold_state = 'held' """, (task.property_id, task.lease_id), ) except sqlite3.IntegrityError: return False return True def handle_delivery( db: sqlite3.Connection, queue: Queue, delivery_id: str, task: CleanupTask, ) -> None: apply_cleanup(db, task) queue.acknowledge(delivery_id) The insert and the state change share a transaction in this example. In a service with separate databases, use an outbox or another explicit coordination design; do not pretend two independent writes are atomic. The handler also acknowledges a duplicate delivery. That is important: leaving a known-complete message unacknowledged creates an unnecessary retry cycle.
There is one deliberate simplification here. A production schema would likely keep an audit record, an actor or reason, and a timestamp. It would also validate that the task's property and lease still belong together. Those are domain checks, not transport features.
Test the dangerous interval directly: commit the release, stop the process before acknowledgment, deliver the task again, and assert that the second delivery changes no lease. Run that test through both the webhook adapter and the polling adapter. A test that only checks a successful HTTP response misses the failure that matters.
The useful test is a small timeline, not a happy-path assertion. At 02:00, the scheduler publishes cleanup_id=lease-expiry:property-1842:lease-77:2026-08-10. At 02:01, a consumer reads it and the database transaction changes the hold from held to released, inserts the audit row, and commits. At 02:01 and a fraction, the process is terminated before its delivery acknowledgment leaves the machine. The queue quite correctly makes the message available again. The second consumer loads the task, attempts the unique insert, sees that the cleanup ID already has a committed result, skips the state-changing work, and acknowledges its own delivery. The observable outcome is one released hold, one audit event, and two delivery attempts. That last number is not a failure; it is evidence that the system recovered from the crash boundary. If the test instead reports two audit events or two outbound notifications, the bug is in the idempotency boundary. If it reports one audit event but the message remains available, the acknowledgment path is wrong. This is the kind of case I keep in the eval harness because it exercises the contract that production will eventually exercise, even if the first demo never does.
Measure Europe-US Behavior Before Changing Transport
Keep the data flow plain. The scheduler emits a small task reference, not a whole property record or an AI prompt. The consumer loads current state, checks the due condition, applies the idempotent transaction, writes an audit event, and acknowledges. If the task is not safe to retry, it is not ready for a queue.
The public endpoint should authenticate the sender before parsing expensive input, validate the task shape, and hand off quickly. It should not keep an HTTP request open while it scans thousands of properties. A 2xx response should mean the delivery has been accepted according to the selected handoff contract, not that an unrelated downstream job happened to start.
The polling worker needs the matching discipline. Claim or receive one message, process it, and acknowledge only after the durable result is committed. On a transient failure, let the queue's retry policy or visibility timeout make the message available again. Add exponential backoff around the receive operation, and treat a rate-limit response as a scheduling signal rather than an invitation to spin.
The example task can stay small:
def build_cleanup_task(property_id: str, lease_id: str, run_date: str) -> dict: return { "cleanup_id": f"lease-expiry:{property_id}:{lease_id}:{run_date}", "property_id": property_id, "lease_id": lease_id, "reason": "hold_expired", } Do not put mutable facts such as the full tenant profile in the idempotency key. Put stable identity in the key and read current state during processing. That makes a retry converge on the current record instead of replaying stale input.
Move to a Workflow Engine When the Graph Grows
The geography changes the operational questions, not the basic delivery semantics. Decide where queue messages, property records, and audit data live. Keep sensitive payloads out of the message when a short identifier will do, and set retention according to the business and legal requirement. A public webhook also needs a route reachable from every region that can deliver it; a private poller needs egress and a clear home for its data.
| Decision | Public HTTPS push | Polling worker |
|---|---|---|
| Inbound network | Requires a reachable HTTPS target | No inbound target required |
| Runtime ownership | Web service owns acceptance and handoff | Worker owns receive loop and concurrency |
| Failure to test | Timeout after business commit | Crash between commit and acknowledgment |
| Local development | Needs a reachable tunnel or staging ingress | Runs naturally as a local process |
| Best starting signal | Existing monitored application endpoint | Existing private worker deployment |
For a small team serving Europe and the US, I would measure three things before changing transport: duplicate side effects per thousand tasks, time from due date to completion, and operator time spent recovering stuck deliveries. Add queue age, retry count, and cleanup result to the same evaluation record. Notebook-to-prod work gets safer when the harness tests the operational contract, not just the function that releases a lease.
A FIFO queue can help with ordering and deduplication semantics, but application-level idempotency remains necessary. Ordering is not a substitute for a unique business key, especially when two cleanup jobs can refer to the same lease. Managed messaging systems also differ in push, pull, retention, and delivery behavior, so read the chosen service's contract rather than importing assumptions from another queue.
This pattern is not suitable when cleanup must coordinate a long multi-step workflow with human approval, compensation, or fan-out and join. Use a workflow engine for that shape. An event log is a better fit when independent consumers need durable replay. A queue plus one idempotent worker is enough for a periodic release; it becomes a poor abstraction once the business process has a graph.
It is also the wrong choice if the team cannot operate either a public ingress or a continuously supervised worker. A scheduler does not replace on-call ownership. In that case, reduce the job's scope, use an existing managed runtime, or postpone the automation until completion and recovery can be observed.
The operational checklist is short: name the business task, persist a unique idempotency key, make the side effect conditional, commit the result before acknowledgment, retry only transient failures, and alert on age rather than only on process health. Then kill the worker at the awkward point and run the test again.
That's it. The transport is a deployment decision. The idempotency boundary is the product's reliability decision.