Skip to main content

Push delivery

Sending one notification to one device is a single HTTP call. Sending to fifteen thousand people when a post goes live is not: some devices are offline, the provider rate-limits you, a worker crashes mid-run, the same campaign gets triggered twice, and someone deletes their account while a send to them is in flight. The hard part of push is never the send — it is the queue, the retries and the races around it.

DartWay puts that hard part in one place: the dartway_push_server Serverpod module. It is optional — an app that needs push adds the dependency; an app that does not gets none of the dw_push_* tables. The module owns the reliable machinery; your app owns everything that is specific to your domain.

What each side owns

The module owns a durable queue (one stored message, compact membership rows per recipient, short pending delivery rows), a worker that claims work with FOR UPDATE ... SKIP LOCKED and leases it, backoff retries, deduplication, lease recovery after a crash, hourly metric buckets, bounded cleanup, and two built-in providers — DwFcmPushProvider (FCM HTTP v1) and DwRuStorePushProvider, both pure Dart.

Your app owns who receives a message, when it is enqueued, the device tokens and their lifecycle, user consent and categories, provider credentials, and — this part matters — authorization: who may send a marketing blast, pause the worker, or read metrics. The module ships no endpoints and no gating, so it can never leak an "open to everyone" default; gating those privileged operations is your app's job, where the role model lives.

Wiring it up

Push is not part of DwCore — your app constructs and owns a DwPush:

DwPush? dwPush;

void bootPush(Map<String, String> passwords) {
final fcmConfig = DwFcmPushProviderConfig.fromPasswords(passwords);
final fcm = fcmConfig.isConfigured ? DwFcmPushProvider(config: fcmConfig) : null;
if (fcm == null) return; // no provider configured → push stays inert

dwPush = DwPush(
config: DwPushConfig(
recipientResolver: AppPushRecipientResolver(),
transport: DwPushProviderTransport(provider: fcm),
),
);
}

DwPushConfig is deliberately small. recipientResolver and transport are the whole surface most apps ever write. Every performance and retention knob lives on an optional fineTuning: DwPushFineTuning(...), where each field documents what it trades off — so the constructor stays readable and you are never staring at a dozen numbers you don't understand. The one knob worth knowing is maxConcurrentDeliveries: each in-flight send holds a database connection for the whole provider round-trip, so keep it a small fraction of your Postgres pool.

The domain seam

Two objects carry your domain into the engine.

DwPushRecipientResolver.resolve runs under a per-recipient advisory lock inside a transaction. Given a recipient id, return the active device tokens that are eligible right now — after checking consent, disabled categories and account deletion. An empty list is a clean "skip this one". Tokens the provider rejects are removed through invalidateTargets.

DwPushTransport sends resolved targets. The default DwPushProviderTransport wraps one or two providers; give it a fallbackProvider and it falls back only on an explicit "this isn't my kind of token" signal — never after a timeout, a 429 or a 5xx, which are retried instead. Provider outcomes are a small classified enum, not a raw response body, and raw tokens or bodies never reach your logs — only a fingerprint.

Enqueue, worker, cleanup

Enqueue is idempotent on a stable deduplicationKey, enforced by a unique index as the last guard, so triggering the same logical campaign twice does not double-send:

await dwPush!.queue.enqueue(
session,
message: DwPushMessageInput(
deduplicationKey: stableKey,
category: category,
title: title,
body: body,
expiresAt: expiresAt,
),
recipientIds: recipientIds,
);

Your app schedules the worker with DwRecurringFutureCall and calls dwPush!.processBatch(session) and dwPush!.cleanup(session). Several instances can run at once safely. Delivery is at-least-once: a crash right after the provider accepted a message can produce a duplicate — a deliberate, documented tradeoff, since for notifications a rare duplicate beats a silent drop. The attempt counter only advances on a real send, so recovering a crashed worker's lease never burns an attempt. On its first batch each process, the engine self-applies tightened autovacuum settings to its two high-churn tables, so the dead rows from its delete-as-you-go design are reclaimed under load without any manual migration step.

Deleting an account

Removing a user's tokens, deliveries and recipient state races with a send already in flight — so do it under the same lock the worker takes before a send:

await dwPush!.withRecipientLock(session, recipientId: userId, action: (tx) async {
await dwPush!.queue.cancelRecipient(session, userId, transaction: tx);
await dwPush!.recipientState.clearRecipient(session, userId, transaction: tx);
// delete app-owned tokens and the user in the same transaction
});

After the callback returns, no earlier delivery can begin a new send to that user, and your audience selection must exclude them from future enqueues for good.

Watching a campaign

dwPush!.campaignProgress(session, messageId: id) returns total, done and remaining for a message, computed on demand from two COUNT(*)s. It is cheap enough to poll from an admin dashboard and never contends with the worker, because the engine keeps no hot per-message counter. It is a few-seconds-fresh snapshot, not a live millisecond gauge — the right tradeoff for a progress view.

Device tokens and stale cancellation

Two pieces of plumbing every push app needs ship with the module, so you don't rebuild them. DwDevicePushToken plus DwDevicePushTokenStore and DwDevicePushTokenPolicy keep device tokens canonical (whitespace-normalized, deduped), bounded (a per-recipient cap with newest-first eviction) and usable (filter out recipients with no valid token before you enqueue). And when a message is queued "about" something that can vanish — a post deleted before its notification goes out — dwPush.linkMessageSources(...) records the link and dwPush.cancelMessagesBySources(...) cancels the message when the source is gone, so an immutable payload never ships stale.

See also

  • Advisory locks — the non-blocking primitive the delivery and account-deletion guards are built on.
  • Recurring jobs — how to schedule the worker.