Re-Engagement Push Campaign Strategies
Subscribers go quiet. They grant permission, click a notification or two, then drift. A re-engagement campaign is the systematic, automated answer: detect dormancy, send a paced sequence designed to pull users back, and stop the moment they return. This guide covers the lifecycle triggers, dormancy windows, drip cadence, and the scheduled job that ties it together — with suppression rules so you never nag someone who is already active.
Problem statement
A single “we miss you” blast is the most common — and most damaging — re-engagement mistake. It treats a churned user and a yesterday-active user identically, burns notification goodwill, and inflates your block and unsubscribe rates. Effective re-engagement is conditional: it fires only against subscribers who cross a defined inactivity threshold, escalates over a multi-touch sequence, and exits the instant a user re-engages or signals fatigue.
This guide assumes you already deliver transactional push reliably. Re-engagement is a layer on top of working delivery infrastructure, not a replacement for it.
A re-engagement program has three moving parts that must agree with each other: a definition of who is dormant (the dormancy window), a sequence of escalating messages (the drip cadence), and a set of conditions that pull a subscriber out of the program (suppression). When any one of these is sloppy, the whole campaign degrades — a too-aggressive window enrolls people who were never really gone, a too-long sequence converts goodwill into block events, and weak suppression sends “we miss you” to someone who logged in an hour ago. The sections below build each part in turn and then wire them together in a single idempotent scheduled job.
Why drip cadence matters more than copy
Teams obsess over the wording of re-engagement messages and underinvest in when they arrive. In practice the cadence dominates outcomes. A subscriber who gets three thoughtfully spaced touches over a week behaves very differently from one who gets the same three messages on three consecutive days. Spacing gives the user room to return on their own (counting as a reactivation), reduces the perception of pestering, and spreads your send volume so it never spikes against push-service rate limits. The drip cadence is therefore both an engagement lever and a throughput control.
Prerequisites
- protocols and browser implementation reference).
last_active_at,last_notified_at, and click events.- exponential backoff for failed deliveries so transient push-service errors don’t poison the campaign.
- TTL and expiration handling.
Concept: lifecycle triggers and dormancy windows
Re-engagement is driven by lifecycle state transitions, not the calendar. A subscriber moves through states: active → cooling → dormant → lapsed. You define each transition by an inactivity window measured from last_active_at.
The right window depends on your product’s natural usage frequency. A daily news app treats 7 days of silence as dormant; a B2B SaaS tool measured in weekly logins might wait 21–30 days. Set the window too short and you interrupt people who were simply busy; too long and the relationship is already cold. A practical starting point:
| State | Inactivity window | Action |
|---|---|---|
active |
0 – W days | None (suppress) |
cooling |
W – 2W days | Optional soft nudge |
dormant |
2W – 6W days | Enter win-back sequence |
lapsed |
> 6W days | Final touch, then archive |
Here W is your product’s baseline session interval. The win-back sequence for dormant users deep-dive covers the three-touch structure and incentive design in detail; this guide focuses on the scheduling and suppression machinery around it.
Suppressing the already-active
The single most important rule: never send a re-engagement message to someone who is active. Activity is defined broadly — a recent session, a recent push click, even a recent in-app open. Your selection query must exclude anyone whose last_active_at falls inside the active window, and your sequence must abort mid-flight if the user returns between touches. Returning users are your success metric, not your audience.
Suppression has two layers. The first is the enrollment gate: at selection time, exclude anyone who is active, opted out, hard-bounced, or already messaged within a cooldown. The second is the mid-sequence exit: between touches, a subscriber can return, opt out, or have their endpoint expire — any of which must cancel the remaining touches immediately. The cleanest way to enforce the exit is to re-check eligibility at send time rather than trusting the snapshot from the previous run. A subscriber who reactivated yesterday should be skipped today even if their cursor says a touch is due. This is why the selection query in Step 2 joins live activity data rather than relying on a precomputed enrollment list.
Beyond activity, maintain a global frequency cap that spans every campaign you run, not just re-engagement. A subscriber receiving a transactional alert, a promotion, and a win-back touch in the same hour experiences your brand as noise regardless of how well-targeted each message is individually. The re-engagement scheduler should consult that shared cap before enqueueing anything.
Step-by-step implementation
Step 1 — Model the campaign state
Track each subscriber’s position in the sequence so a daily job is idempotent and resumable. Store the campaign cursor alongside the subscription.
-- Campaign enrollment / cursor table
CREATE TABLE reengagement_state (
subscription_id UUID PRIMARY KEY REFERENCES push_subscriptions(id),
sequence_step SMALLINT NOT NULL DEFAULT 0, -- 0 = not enrolled, 1..3 = touches sent
enrolled_at TIMESTAMP,
last_touch_at TIMESTAMP,
next_touch_due_at TIMESTAMP,
reactivated BOOLEAN NOT NULL DEFAULT FALSE
);
Step 2 — Select dormant, suppressible subscribers
The selection query is where suppression lives. Pick subscribers who crossed the dormancy threshold, are not active, are not opted out, and are due for their next touch.
-- Dormant subscribers eligible for the next touch
SELECT s.id, s.endpoint, s.p256dh_key, s.auth_secret, r.sequence_step
FROM push_subscriptions s
JOIN reengagement_state r ON r.subscription_id = s.id
WHERE s.delivery_status = 'active'
AND s.last_active_at < NOW() - INTERVAL '14 days' -- 2W dormancy window
AND s.consent_log->>'opted_out' IS DISTINCT FROM 'true'
AND r.reactivated = FALSE
AND r.sequence_step < 3
AND (r.next_touch_due_at IS NULL OR r.next_touch_due_at <= NOW())
LIMIT 5000;
Step 3 — Build the touch-specific payload
Each touch escalates: a gentle reminder, then value/proof, then an incentive. Keep payloads small — the encrypted ciphertext must stay under the 4 KB payload limit and use the aes128gcm content encoding required by RFC 8291. Send identifiers and a deep link, not heavy content.
function buildTouch(step) {
const touches = {
1: { title: "Still there?", body: "Here's what you've missed.", url: "/feed" },
2: { title: "Your saved items are waiting", body: "Pick up where you left off.", url: "/saved" },
3: { title: "A little something to come back to", body: "Unlock your welcome-back perk.", url: "/welcome-back" }
};
const t = touches[step];
return { title: t.title, body: t.body, data: { url: t.url, campaign: "reengage", step } };
}
Step 4 — Send the sequence with backoff
Wrap each send in a retry policy. A 410 Gone means the subscription is dead — remove it and do not retry. A 429 or 5xx means back off and try later, which is exactly what your exponential backoff layer handles. Never hardcode the VAPID public key — read it from process.env.
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:lifecycle@yourdomain.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function sendTouch(sub, payload, attempt = 0) {
const subscription = {
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh_key, auth: sub.auth_secret }
};
try {
await webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 86400, // re-engagement is not time-critical; let it sit a day
urgency: 'low' // deprioritize behind transactional traffic
});
return { ok: true };
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
return { ok: false, prune: true }; // dead endpoint
}
if ((err.statusCode === 429 || err.statusCode >= 500) && attempt < 4) {
const delayMs = Math.min(2 ** attempt * 1000, 60000); // exponential, capped
await new Promise(r => setTimeout(r, delayMs));
return sendTouch(sub, payload, attempt + 1);
}
return { ok: false, error: err.statusCode };
}
}
Step 5 — The scheduled job
Tie selection, sending, and cursor advancement into one idempotent job. Schedule it once per day at a fixed local-ish hour; advance the cursor and set the next due date after each successful touch.
const TOUCH_SPACING_DAYS = [0, 3, 4]; // gap before touch 1, 2, 3
async function runReengagementJob(db) {
const rows = await db.query(SELECT_DORMANT_SQL); // Step 2 query
for (const sub of rows) {
const step = sub.sequence_step + 1;
const payload = buildTouch(step);
const result = await sendTouch(sub, payload);
if (result.prune) {
await db.markEndpointGone(sub.id);
continue;
}
if (!result.ok) continue; // backoff exhausted; retried next run
const nextGap = TOUCH_SPACING_DAYS[step] ?? null; // null after touch 3
await db.advanceCursor(sub.id, step, nextGap);
}
}
Configuration reference
| Parameter | Type | Default | Notes |
|---|---|---|---|
dormancyWindowDays |
integer | 14 |
2W; when a subscriber enters the win-back sequence. |
activeWindowDays |
integer | 7 |
W; activity inside this window suppresses all touches. |
touchSpacingDays |
int[] | [0,3,4] |
Days between consecutive touches. |
maxTouches |
integer | 3 |
Sequence length before archiving as lapsed. |
TTL |
integer (s) | 86400 |
One day; re-engagement is not time-critical. |
urgency |
enum | low |
Keeps re-engagement behind transactional sends. |
batchLimit |
integer | 5000 |
Rows per job run; tune to your push-service rate limits. |
backoffCapMs |
integer | 60000 |
Ceiling for exponential retry delay. |
Verification
Confirm the campaign behaves before pointing it at production volume.
# Dry-run the selection against a staging snapshot and count, don't send
psql "$STAGING_URL" -c "SELECT COUNT(*) FROM push_subscriptions s
JOIN reengagement_state r ON r.subscription_id = s.id
WHERE s.last_active_at < NOW() - INTERVAL '14 days'
AND r.reactivated = FALSE AND r.sequence_step < 3;"
Then verify a single send end to end: enroll one test subscription, run the job, and watch the service worker’s push event fire in DevTools (Application → Service Workers → Push). Confirm the cursor advanced and next_touch_due_at is set. Finally, simulate a reactivation by updating last_active_at to now and re-running the job — the row must be skipped and reactivated flipped to true on next sweep.
Error and edge-case matrix
| Condition | Cause | Fix |
|---|---|---|
410 Gone on send |
Subscription expired or browser cleared it | Prune the endpoint; do not retry; exclude from future runs. |
429 Too Many Requests |
Exceeded push-service rate limit | Back off and retry; lower batchLimit. See retry logic and backoff. |
413 Payload Too Large |
Ciphertext over 4 KB | Trim payload to identifiers + deep link only. |
| User returns mid-sequence | Activity between touches | Suppression query and reactivated flag abort remaining touches. |
| Duplicate sends on re-run | Job not idempotent | Cursor + next_touch_due_at gate; never select rows due in the future. |
| Notification shows blank | Service worker didn’t parse payload | Validate JSON in push handler; provide title/body fallback. |
| Touch arrives at 3 a.m. | TTL too short to defer to a sane hour | Use TTL: 86400 so the push service can deliver within a day. |
Measuring whether it works
A re-engagement campaign that you cannot measure is just guessing with extra steps. Instrument three things from day one. First, reactivation rate: the share of enrolled dormant subscribers who take a meaningful action within an attribution window after a touch. Measure it per touch so you know which message in the sequence does the work — often touch 1 carries most of the recovery and the later touches earn diminishing returns. Second, cost signals: block and unsubscribe deltas during the campaign window, because a sequence that recovers 5% but doubles your block rate is destroying long-term reach. Third, delivery health: the 410 Gone rate, which runs far higher here than in transactional traffic because dormant devices are precisely the ones whose endpoints have rotated.
The only honest way to credit the campaign for reactivations is a holdout. Randomly exclude a slice of otherwise-eligible dormant subscribers, send them nothing, and compare their natural return rate against the treated group. The difference is true lift. Without a holdout you will attribute organic returns to your push and conclude the campaign works better than it does. Tag every deep link with the campaign and step (campaign=reengage&step=2) so clicks reconcile cleanly against the sends your job recorded, and exclude failed sends from the denominator so an unreachable endpoint isn’t counted as an ignored message.
Cross-browser notes
Re-engagement timing interacts with how vendors handle idle subscriptions. Chromium throttles delivery to long-idle devices and may collapse low-urgency messages; Firefox (Mozilla Autopush) holds messages for the full TTL; Safari/WebKit on iOS enforces stricter background limits, so a low-urgency re-engagement push can be delayed noticeably. Because re-engagement targets the least-active devices, expect a higher 410 rate here than in transactional traffic — these subscribers are precisely the ones whose endpoints have rotated or expired. Lean on your TTL policy so the push service can buffer and deliver when the device next checks in.
Related
- Back to Notification Engagement & Campaign Optimization — the overview tying targeting, testing, analytics, and lifecycle campaigns together.
- Win-Back Push Campaigns for Dormant Users — the three-touch sequence, incentives, and how to measure reactivation.
- Use-Case Playbooks — end-to-end examples including SaaS re-engagement built on these patterns.
- Retry Logic & Backoff Strategies — the resilient send layer your scheduled job depends on.
- TTL & Expiration Handling — why low-urgency campaigns want a long TTL.
FAQ
How long should the dormancy window be before re-engagement starts?
Anchor it to your product’s natural usage frequency, not a fixed number. A good rule is roughly twice your baseline session interval (2W): if engaged users return weekly, treat 14 days of silence as dormant. Validate by checking the return rate of users at different inactivity ages — the window where return rates fall off a cliff is where re-engagement earns its keep.
Why suppress active users so aggressively?
Sending re-engagement messages to people who are already engaged is the fastest way to drive blocks and unsubscribes. The whole campaign is conditional on inactivity. Define activity broadly — sessions, push clicks, in-app opens — and exclude anyone inside the active window from selection, while also aborting an in-progress sequence the moment a user returns.
What TTL and urgency should re-engagement pushes use?
Use a long TTL (around 86400 seconds) and urgency: low. Re-engagement is not time-critical, so a long TTL lets the push service buffer and deliver when the device next reconnects, and low urgency keeps these sends from competing with transactional traffic. See TTL and expiration handling for the trade-offs.
How do I keep the scheduled job from sending duplicates?
Make the job idempotent with a per-subscriber cursor (sequence_step) and a next_touch_due_at gate. The selection query only returns rows whose next touch is already due, and the cursor advances only after a confirmed send. Re-running the same day is then a no-op for anyone already touched.