Skip to content
Tech Stack4 November 2026 · 20 min read

Defer Firebase Push During Quiet Hours Without Loss

A push skipped during quiet hours but marked sent never reaches a retry. Record a deferral with a wake time, claim each row with a lease, and mark it accepted only when FCM returns an ID.

Defer Firebase Push During Quiet Hours Without Loss

What you'll learn

  • Diagnose the lost notification first
  • Give each meaning of "sent" its own state
  • Compute the wake time in the recipient's time zone
Article contents

This is the line that loses the notification:

if (isQuietHours(user, now)) {
  await ref.update({ status: "sent" }); // nothing was sent
  return;
}

The code skips the push because the user is asleep, then writes the same status a real send would write. Nothing ever looks at that row again. No retry picks it up after 07:00, the alert history shows it as handled, and support has no way to tell a skipped message from one that FCM accepted.

The fix is to record quiet hours as a deferral with a wake time, keep it separate from a delivery attempt, and only write an "accepted" state when Firebase Cloud Messaging returns a message ID. A worker that runs every minute picks up rows whose time has come, claims each one with a short lease so two workers cannot both own it, sends it, and records what FCM actually said. The user's in-app history is written when the event happens, so it is complete even when a push never arrives.

Everything below is a proposed reference implementation for Cloud Functions in TypeScript, using synthetic users and messages. The outbox and quiet-hours code ran against the Firestore emulator with a fake FCM sender; the real FCM call, the deployed scheduler and the Flutter screen were not executed, and the article says where each boundary is.

Diagnose the lost notification first

Before changing code, confirm which failure you have. Search your outbox or alert collection for rows created during quiet hours and compare them with what the send path writes.

What you seeWhat to checkLikely cause
Row says sent, but no FCM message ID is storedDoes the quiet-hours branch write the same status as a send?Deferral recorded as delivery
Row created at night, still pending at noonIs anything querying for deferred rows after the window ends?No wake time, or no job that reads it
Notification arrives at 06:00 for some users and 08:00 for othersWhich time zone does the check use?Server zone or one fixed zone instead of the recipient's
Users get two banners for one eventCan two workers or two runs send the same row?No claim step, or retries without an ID
A "table is ready" push arrives the next morningDoes the message have an expiry?Deferral without a time limit

If the first row matches, the rest of this guide applies directly. The other rows are failures the same design closes once the first one is fixed.

Give each meaning of "sent" its own state

Four different events tend to share one sent flag: the app decided to wait, the worker tried to send, FCM accepted the request, and the device showed something. The server can observe the first three. It cannot observe the fourth from the send call.

FCM's Admin SDK documentation says a successful send returns a message ID in the form projects/{project_id}/messages/{message_id}. Firebase's delivery reporting guide describes the matching BigQuery event, MESSAGE_ACCEPTED, as the message being "received by the FCM server and the request is valid". Delivery to the app's FCM SDK on the device is a separate MESSAGE_DELIVERED event in that export, and the guide notes it is not propagated by default. So the outbox stops at "accepted".

State diagram of a notification outbox row. Queued, deferred and claimed are open states. A quiet-hours check moves a queued row to deferred without counting an attempt. A claim counts one attempt and holds a two-minute lease, and an expired lease makes the row claimable again. FCM success leads to accepted; a permanent error leads to failed; a retryable error returns to queued; a newer message leads to superseded; passing the expiry leads to expired.

Read the diagram from the top. Only the claim step increases attempts, so a message that waited through quiet hours still has its full retry budget at 07:00. The four terminal states each answer a different support question.

StateMeaningWho writes it
queuedReady to be checked now or at nextCheckAtenqueue, or finish after a retryable error
deferredUser policy says wait until deferredUntilclaim, when quiet hours apply
claimedOne worker holds a lease and may be sendingclaim
acceptedFCM returned a message IDfinish
failedPermanent FCM error, no token, or too many attemptsclaim or finish
supersededA newer message about the same thing replaced it before sendingenqueue
expiredThe message stopped being useful before it could be sentclaim

There is deliberately no delivered or read state. If the product needs read status, take it from the in-app history, where the app itself records that the user opened the item.

Compute the wake time in the recipient's time zone

Quiet hours belong to the recipient, so store their time zone as an IANA identifier such as Europe/Sarajevo together with the window. On the device, Dart's DateTime.timeZoneName is "provided by the operating system and may be an abbreviation or a full name", so it is a poor key for server calculations. Let the user pick the zone in settings, or use a plugin that returns the IANA name, and validate it on save.

On the server, Intl.DateTimeFormat accepts any IANA time zone name and throws a RangeError for invalid options, which gives you the validation for free:

export type QuietHours = {
  timeZone: string; // IANA ID chosen by the user, e.g. "Europe/Sarajevo"
  start: string;    // "22:00" local wall-clock time
  end: string;      // "07:00"
};

const formatters = new Map<string, Intl.DateTimeFormat>();

function localMinutes(at: Date, timeZone: string): number {
  let f = formatters.get(timeZone);
  if (!f) {
    // Throws RangeError for an unknown time zone: validate when the user saves it.
    f = new Intl.DateTimeFormat("en-GB", {
      timeZone, hourCycle: "h23", hour: "2-digit", minute: "2-digit",
    });
    formatters.set(timeZone, f);
  }
  const parts = f.formatToParts(at);
  const hour = Number(parts.find((p) => p.type === "hour")!.value);
  const minute = Number(parts.find((p) => p.type === "minute")!.value);
  return hour * 60 + minute;
}

const toMinutes = (hhmm: string) => {
  const [h, m] = hhmm.split(":").map(Number);
  return h * 60 + m;
};

export function isQuiet(at: Date, q: QuietHours): boolean {
  const now = localMinutes(at, q.timeZone);
  const start = toMinutes(q.start);
  const end = toMinutes(q.end);
  if (start === end) return false;
  return start < end
    ? now >= start && now < end        // same-day window, 13:00-15:00
    : now >= start || now < end;       // overnight window, 22:00-07:00
}

// First instant at or after `at` that is outside the quiet window.
// Walking real instants minute by minute means a local time skipped by a
// DST change is never selected, and a repeated hour resolves to its first pass.
export function nextAllowed(at: Date, q: QuietHours): Date {
  if (!isQuiet(at, q)) return at;
  let t = Math.ceil(at.getTime() / 60_000) * 60_000;
  for (let i = 0; i <= 26 * 60; i++, t += 60_000) {
    if (!isQuiet(new Date(t), q)) return new Date(t);
  }
  throw new Error("Quiet window does not end within 26 hours");
}

The minute walk avoids building local timestamps by hand, which is where daylight saving bugs usually come from. Each step is a real instant, so when clocks jump from 02:00 to 03:00 the skipped local minutes never appear, and when 02:00 to 03:00 repeats in autumn the first matching minute wins. It costs one formatter call per remaining minute of the window, so about 540 calls for a message created at the start of a nine-hour night. That is acceptable for a sweep that handles a bounded batch per minute; cache the result in deferredUntil rather than recomputing it on every run.

Timeline of one synthetic night in Europe/Sarajevo. A message is created at 22:30 local time and deferred until 07:00. A sweep at 06:59 leaves it alone. The sweep at 07:00 claims it, sends it, and records FCM's message ID as accepted. A second lane shows the spring-forward night, where a 02:30 wake time that does not exist resolves to 03:00.

The timeline shows the times the emulator run checked. These are the daylight saving cases worth keeping as tests in your own project:

CaseInput (UTC)Wake time returned
Overnight window, Sarajevo, winter21:30 (22:30 local)06:00 UTC, which is 07:00 local
End time 02:30 skipped by spring forward, 28 March 202700:30 (01:30 local)01:00 UTC, which is 03:00 local
End time 02:30 repeated by fall back, 25 October 202623:30 the day before00:30 UTC, the first 02:30
New York, night the clocks go back, 1 November 202603:00 (23:00 local)12:00 UTC, nine real hours later
New York, night the clocks go forward, 14 March 202703:00 (22:00 local)11:00 UTC, eight real hours later

Do not encode quiet hours in the scheduler's cron expression. The job runs on a fixed UTC interval for everyone, and the per-user decision happens inside it. The price alerts article explains why a cron schedule tied to one zone misbehaves around daylight saving changes.

Write the history row before any push

The outbox row and the user's inbox row are created in one transaction when the event happens. The push becomes a nudge toward something that already exists in the app.

export async function enqueue(
  db: Firestore,
  n: { id: string; userId: string; title: string; body: string;
       supersedeKey?: string; ttlMinutes: number },
  now: Date,
) {
  const outbox = db.collection("outbox");
  await db.runTransaction(async (tx) => {
    // Reads first: an older, unsent message for the same thing is replaced.
    const older = n.supersedeKey
      ? await tx.get(outbox
          .where("userId", "==", n.userId)
          .where("supersedeKey", "==", n.supersedeKey)
          .where("state", "in", ["queued", "deferred"]))
      : null;
    for (const doc of older?.docs ?? []) {
      tx.update(doc.ref, { state: "superseded", supersededBy: n.id, nextCheckAt: null });
    }
    // create() fails if the id exists, so a retried trigger cannot enqueue twice.
    tx.create(outbox.doc(n.id), {
      userId: n.userId, title: n.title, body: n.body,
      supersedeKey: n.supersedeKey ?? null,
      state: "queued", attempts: 0,
      createdAt: ts(now),
      expiresAt: ts(new Date(now.getTime() + n.ttlMinutes * 60_000)),
      nextCheckAt: ts(now),
    });
    // The in-app history is written with the outbox row, not after a push.
    tx.create(db.doc(`users/${n.userId}/inbox/${n.id}`), {
      title: n.title, body: n.body, createdAt: ts(now), read: false,
    });
  });
}

Three choices in this function prevent later bugs. The notification ID comes from the caller, so a Firestore trigger can pass its event ID: Cloud Functions provides at-least-once execution for event-driven functions, and a second create() with the same ID fails instead of queueing a copy. The supersedeKey lets "Delivered" replace a still-waiting "Out for delivery" for the same order, while a message another worker is already sending is left alone. The expiry is set at creation, because only the producer knows how long the message stays useful.

The supersede query filters on three fields. The test run below did not exercise production index requirements, so run the query once against a deployed project and create any index Firestore asks for.

The same history collection is what the SaaS notification system guide calls the in-app inbox. This article only covers how the push channel feeds it.

Claim with a lease, then decide

The claim step is where quiet hours are evaluated, because a user can change their settings between enqueue and send. It runs in a transaction, reads the outbox row and the user profile, and writes exactly one decision.

const LEASE_MS = 2 * 60_000;
const MAX_ATTEMPTS = 5;

export async function claim(
  db: Firestore, id: string, workerId: string, now: Date,
): Promise<Claim | null> {
  const ref = db.collection("outbox").doc(id);
  return db.runTransaction(async (tx) => {
    const snap = await tx.get(ref);
    const d = snap.data();
    if (!d || !d.nextCheckAt || d.nextCheckAt.toMillis() > now.getTime()) {
      return null; // terminal, or another worker already moved it forward
    }
    const user = (await tx.get(db.doc(`users/${d.userId}`))).data() ?? {};
    const expiresAt: Date = d.expiresAt.toDate();

    if (now >= expiresAt) {
      tx.update(ref, { state: "expired", nextCheckAt: null });
      return null;
    }
    if (d.attempts >= MAX_ATTEMPTS) {
      tx.update(ref, { state: "failed", lastError: "max-attempts", nextCheckAt: null });
      return null;
    }
    const quiet: QuietHours | undefined = user.quietHours;
    if (quiet && isQuiet(now, quiet)) {
      const wake = nextAllowed(now, quiet);
      // A policy deferral is not a delivery attempt: attempts stays the same.
      tx.update(ref, wake >= expiresAt
        ? { state: "expired", nextCheckAt: null }
        : { state: "deferred", deferredUntil: ts(wake), nextCheckAt: ts(wake) });
      return null;
    }
    if (!user.fcmToken) {
      tx.update(ref, { state: "failed", lastError: "no-token", nextCheckAt: null });
      return null;
    }
    const leaseUntil = new Date(now.getTime() + LEASE_MS);
    tx.update(ref, {
      state: "claimed", leaseOwner: workerId,
      leaseUntil: ts(leaseUntil), nextCheckAt: ts(leaseUntil), // an expired lease is due again
      attempts: d.attempts + 1,
    });
    return { id, token: user.fcmToken, title: d.title, body: d.body, expiresAt };
  });
}

The send happens outside this transaction. Firestore's transaction documentation warns that "transaction functions can run multiple times" when there is contention, so an FCM call inside the function could fire once per retry.

Setting nextCheckAt to the lease end is what makes a worker restart safe. If the worker that claimed a row dies, the row becomes due again two minutes later and the next sweep claims it with a new lease and a second attempt. Choose a lease comfortably longer than one send takes, including the HTTP timeout you configure.

A row that is deferred keeps its wake time even if the user turns quiet hours off later. If that matters for your product, reset nextCheckAt to now on deferred rows for that user when they save new settings.

Send with an expiry and a stable tag

The message carries the outbox ID in its data payload and as the Android notification tag. FCM's message reference describes tag as an "identifier used to replace existing notifications in the notification drawer", which limits the damage of the duplicate described below.

export function buildMessage(c: Claim, now: Date): Message {
  const ttlMs = Math.max(0, c.expiresAt.getTime() - now.getTime());
  return {
    token: c.token,
    notification: { title: c.title, body: c.body },
    data: { notificationId: c.id },                  // lets the app de-duplicate
    android: { ttl: ttlMs, notification: { tag: c.id } },
    apns: { headers: { "apns-expiration": String(Math.floor(c.expiresAt.getTime() / 1000)) } },
  };
}

The remaining lifetime goes to FCM as well, so a message that waited in your outbox does not then wait up to four more weeks on FCM's side. Firebase documents a lifespan of 0 to 2,419,200 seconds (28 days), and a message sent without one lasts up to four weeks, and the REST reference says the backend sets a 30-day default for apns-expiration. The Admin SDK's android.ttl field is in milliseconds, unlike the REST field, so check the type definition of the version you install.

Record only what FCM told you

After the send, a second transaction writes the outcome, but only if this worker still holds the lease.

const PERMANENT = new Set([
  "messaging/registration-token-not-registered",
  "messaging/invalid-registration-token",
  "messaging/invalid-argument",
  "messaging/mismatched-credential",
  "messaging/third-party-auth-error",
]);

export async function finish(
  db: Firestore, id: string, workerId: string, result: SendResult, now: Date,
): Promise<State | "lease-lost"> {
  const ref = db.collection("outbox").doc(id);
  return db.runTransaction(async (tx) => {
    const d = (await tx.get(ref)).data();
    if (!d || d.state !== "claimed" || d.leaseOwner !== workerId) {
      return "lease-lost"; // another worker owns it now; do not overwrite its result
    }
    if ("messageId" in result) {
      // FCM accepted the request. This is not delivery, display or a read.
      tx.update(ref, { state: "accepted", fcmMessageId: result.messageId,
                       acceptedAt: ts(now), nextCheckAt: null, leaseOwner: null });
      return "accepted";
    }
    if (PERMANENT.has(result.errorCode)) {
      tx.update(ref, { state: "failed", lastError: result.errorCode,
                       nextCheckAt: null, leaseOwner: null });
      return "failed";
    }
    // Retryable: exponential backoff from one minute, capped at one hour, with jitter.
    const delay = Math.min(60 * 60_000, 60_000 * 2 ** (d.attempts - 1));
    const retryAt = new Date(now.getTime() + delay + Math.random() * 10_000);
    tx.update(ref, { state: "queued", lastError: result.errorCode,
                     nextCheckAt: ts(retryAt), leaseOwner: null });
    return "queued";
  });
}

The split between permanent and retryable errors follows FCM's error code reference. The Admin SDK reports these with its own messaging/... codes:

FCM errorAdmin SDK codeOutbox action
UNREGISTEREDmessaging/registration-token-not-registeredfailed; remove the token, as the reference instructs
INVALID_ARGUMENTmessaging/invalid-argumentfailed; fix the payload before any retry
SENDER_ID_MISMATCHmessaging/mismatched-credentialfailed; configuration problem
THIRD_PARTY_AUTH_ERRORmessaging/third-party-auth-errorfailed; check APNs or web push credentials
UNAVAILABLEmessaging/server-unavailablequeued with backoff
INTERNALmessaging/internal-errorqueued with backoff
QUOTA_EXCEEDEDmessaging/message-rate-exceeded and related codesqueued; the reference asks for exponential backoff, starting at one minute or more for overall and topic limits

For UNAVAILABLE, the reference also asks you to honor a Retry-After header when one is present. This sketch does not read it, so add that if you see sustained 503 responses.

The sweep that runs every minute

The worker queries due rows, then claims, sends and finishes them one at a time.

export async function sweep(
  db: Firestore,
  send: (m: Message) => Promise<string>,
  workerId: string,
  now: Date,
) {
  // Terminal rows store nextCheckAt: null, and claim() refuses them either way.
  const due = await db.collection("outbox")
    .where("nextCheckAt", "<=", ts(now))
    .orderBy("nextCheckAt").limit(100).get();

  for (const doc of due.docs) {
    const c = await claim(db, doc.id, workerId, now);
    if (!c) continue;
    let result: SendResult;
    try {
      result = { messageId: await send(buildMessage(c, now)) };
    } catch (e: any) {
      result = { errorCode: e?.code ?? "unknown" };
    }
    await finish(db, c.id, workerId, result, now);
  }
}
import { onSchedule } from "firebase-functions/scheduler";
import { getFirestore } from "firebase-admin/firestore";
import { getMessaging } from "firebase-admin/messaging";
import { initializeApp } from "firebase-admin/app";
import { randomUUID } from "node:crypto";
import { sweep } from "./outbox.ts";

initializeApp();

export const notificationOutbox = onSchedule("every 1 minutes", async () => {
  await sweep(getFirestore(), (m) => getMessaging().send(m), randomUUID(), new Date());
});

Firebase's scheduled functions guide warns that a scheduled function may be triggered more than once and that a new run can start while the previous one is still executing. The claim transaction exists for exactly that case. A one-minute schedule also means a deferred message goes out up to about a minute after the wake time.

If you prefer one task per message, Cloud Tasks can schedule each send at its wake time. Its own documentation says duplicate executions do occur and that tasks can be delayed by a few minutes, so the same claim and lease logic would still be needed.

The duplicate you cannot rule out

One failure remains after all of this. If a worker sends the message, FCM accepts it, and the worker dies before finish() commits, the lease expires and another worker sends it again. Nothing in this design lets FCM recognize the second request as a repeat of the first, so the outbox guarantees at least one attempt and cannot guarantee exactly one.

Two identical cream paper slips hanging on twine from one brass hook against a dark wall, the front slip partly covering the one behind it, with one blank brass tag tied to both.

The image stands for what the emulator run produced in that scenario: two sends that carry the same notificationId. On Android the shared tag means the second banner replaces the first in the notification drawer. In the app, treat the inbox document as the record and use notificationId to avoid showing an in-app banner twice. The duplicate cannot be removed from the server side; the two IDs only make it harmless on the client.

The risk is limited to the time between FCM's response and the finish() commit. Keep the scenario in your test suite so a later refactor cannot widen that window unnoticed, for example by moving other work between the send and the commit.

Show the history in Flutter

The Flutter side reads the inbox, which already contains every message regardless of what happened to the push:

final inbox = FirebaseFirestore.instance
    .collection('users')
    .doc(uid)
    .collection('inbox')
    .orderBy('createdAt', descending: true)
    .limit(50)
    .snapshots();

This matters more than it looks. FlutterFire's receive messages guide says notification messages that arrive while the app is in the foreground "won't display a visible notification by default", and that on Android, if the user force-quits the app from device settings, it "must be manually reopened for messages to start working". A user who missed the banner still finds the item in the list. This Dart snippet was not run as part of the test; it is the standard Firestore query shape, so adjust it to your own collection names. The Flutter and Firebase SaaS stack overview covers the surrounding app structure.

Keep the deferral on the server. Holding a message on the device until morning would depend on background execution that the operating system controls, and the same FlutterFire guide warns that if background handler tasks run longer than 30 seconds, the device may kill the process. The guide to Flutter background work and isolates covers those limits in more detail.

FlutterFlow's built-in push notifications can schedule a send, but its documentation says the selected date and time "follow your timezone", meaning the sender's. It does not describe per-recipient quiet hours, so this outbox runs as your own Cloud Function next to FlutterFlow's deployed functions, and the FlutterFlow app only reads the inbox collection.

What the emulator run showed

The outbox code ran with firebase-admin 13.10.0 against the Firestore emulator (v1.19.8) on Node 22, with a fake sender in place of getMessaging().send() and simulated clock values passed as now. All scenarios passed:

ScenarioResult
Message at 22:30 local, quiet 22:00 to 07:00deferred, attempts 0, wake 07:00 local
Sweep at 06:59 localNothing sent
Sweep at 07:00 localSent once, accepted with the fake message ID, attempts 1
Worker claims, then dies before sendingNo re-claim at +1 minute; another worker sends once at +2 minutes; the late finish() from the dead worker is rejected
Worker dies after FCM accepts, before finish()Sent twice with the same notificationId (documented limit)
Two sweeps at the same moment over three rowsThree sends in total, attempts 1 on each row
messaging/server-unavailablequeued with a 63-second backoff, accepted on the next attempt
messaging/registration-token-not-registeredfailed, no message ID, never retried
"Delivered" arrives while "Out for delivery" is deferredFirst row superseded, only the second pushed at 07:00
60-minute message created at 23:00 localexpired instead of waking the user at 07:00
Same notification ID enqueued twiceSecond create() rejected
Daylight saving cases from the table aboveAll five wake times as listed

Not tested: a real FCM project and device, the deployed scheduler, production Firestore's concurrency mode and index requirements, iOS presentation, and the Flutter inbox screen. The emulator confirms the state logic and the transaction behavior it emulates; it does not prove delivery.

Limits of this design

  • It records FCM acceptance. It does not know whether the device displayed the banner or whether the user saw it.
  • One token per user keeps the example short. With several devices, store tokens in a subcollection and record an outcome per token, because one can fail while another is accepted.
  • Firebase's collapsible message guide says Android stores at most 100 messages without collapsing and that "if the limit is reached, all stored messages are discarded". Supersede keys reduce the volume you send to an offline device; they do not change that limit.
  • Email and SMS fallbacks are out of scope. If you add them, give each channel its own outbox row and state, so a push failure does not overwrite an email result.

Start with the quiet-hours branch in your current send path. If it writes anything other than a wake time, replace that write with deferred and a nextCheckAt, then run the "message at 22:30" and "sweep at 07:00" scenarios against the emulator before touching the rest.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Does a successful FCM send mean the notification was delivered?

No, a successful send only means FCM accepted the request and returned a message ID. Firebase's delivery reporting guide treats delivery to the app's FCM SDK on the device as a separate MESSAGE_DELIVERED event that is not exported by default, and neither event tells you whether the user saw the banner. Store the message ID with an accepted state, and take read status from your in-app history, where the app records that the user opened the item.

Should quiet hours use the server's time zone or the user's?

Use the recipient's time zone, stored as an IANA identifier such as Europe/Sarajevo and evaluated per user inside a job that runs on a fixed UTC interval. On the device, Dart's DateTime.timeZoneName can return an abbreviation, so let the user choose the zone or use a plugin that returns the IANA name. On the server, Intl.DateTimeFormat resolves local time for that zone, and walking real instants handles daylight saving gaps and repeated hours.

Can FCM's ttl option delay a push until quiet hours end?

No, ttl and apns-expiration control how long FCM keeps a message it could not deliver, not when an online device receives it. The FCM message reference describes ttl as how long the message is kept in FCM storage if the device is offline. Hold the message in your own outbox until the wake time, then pass the remaining useful lifetime as the TTL so a stale message cannot arrive days later.

How do I stop users receiving the same push twice?

Claim each outbox row in a transaction with a short lease so two workers cannot send it together, and create rows with a caller-supplied ID so a retried trigger cannot enqueue a copy. That still leaves one gap: a worker that crashes after FCM accepts the message but before recording it will cause a second send. Put the outbox ID in the data payload and the Android notification tag, so the second banner replaces the first and the app can ignore the repeat.

Can FlutterFlow handle per-user quiet hours on its own?

Not according to its current documentation. FlutterFlow's push notification feature can schedule a send, but the selected date and time follow the sender's time zone, and the page does not describe per-recipient quiet hours. Run the outbox as your own Cloud Function next to the functions FlutterFlow deploys, and have the FlutterFlow app read the inbox collection for its notification history.