better-push
Build notifications

Scheduled sends

Send this tomorrow at 9am - with an id you can cancel.

Pass at and the send happens later:

const result = await push.notify("orderShipped", {
  userId,
  payload: { orderId, eta },
  at: new Date("2026-08-01T09:00:00Z"), 
});
// -> { outcome: "scheduled", scheduledId: "sched_…", at: Date }

at works on both notify() forms, typed and ad-hoc.

Nothing is written until it fires

The job carries the original call, not a half-written notification:

type SendJobPayload =
  | { form: "typed"; type: string; args: Record<string, unknown> }
  | { form: "adhoc"; input: Record<string, unknown> };

At the due time a worker replays it through the ordinary notify() pipeline. Three things follow, and they are the reason it is stored this way:

  • Preferences resolve at fire time. A user who opts out between scheduling and firing correctly receives nothing.
  • The device list resolves at fire time. A phone registered yesterday gets the send; one deleted yesterday does not.
  • The definition's current functions run. If you change a title template and redeploy, tomorrow's send uses the new one.

There is no schema change and no partially-written notification to reconcile, because until it fires there is nothing but a job.

Cancelling

const { scheduledId } = result;
await push.cancelScheduled(scheduledId); // -> boolean

False means the id is unknown or the send is already being processed. A cancellation that silently did nothing would be worse than none, so this reports honestly rather than optimistically.

A queue is required

This is the one capability that genuinely cannot degrade: without a queue there is nothing to hold the send, so at throws at the call site with a message naming the fix.

// BetterPushError: notify({ at }) needs a queue to hold the send until it is
// due: add `queue: dbQueue()` or `queue: bullmq({ connection })` to betterPush()

Both backends do it; only precision changes.

Fires
queue: dbQueue()within one poll interval (~1s default) of its due time
queue: bullmq()at its due time, on a native delayed job

Validation

All at the call site, so failures are synchronous and loud:

  • An at in the past (or within a second of now) delivers immediately and returns { outcome: "delivered" }. A scheduler that quietly drops "send this at a time that just passed" is a bug generator.
  • No queue configured throws INVALID_CONFIG, naming the fix.
  • More than a year out is rejected. BullMQ holds delayed jobs in a memory-backed sorted set and dbQueue holds a row every claim scans past; a five-year timer is always a mistake, and finding out now beats finding out in 2031.
  • at that is not a Date throws INVALID_INPUT.

The result union

NotifyResult is discriminated on outcome, so TypeScript forces you to handle the mode you configured - and result.notificationId cannot be read on a send that has not happened yet.

const result = await push.notify("orderShipped", { userId, payload, at });

switch (result.outcome) {
  case "delivered":
    result.notificationId;
    result.deliveries;
    break;
  case "scheduled":
    result.scheduledId;
    result.at;
    break;
  case "digested":
    result.windowId;
    result.windowEndsAt;
    result.itemCount;
    break;
}

Where a caller only wants "did it go out now", it checks result.outcome === "delivered".

When the definition changes before it fires

The replay looks the type up at fire time:

  • A payload the definition no longer accepts, or a type no longer registered at all, is not retryable. The job is dead-lettered immediately and shows up on the studio's queue screen, rather than retrying five times on its way to the same place.
  • A database failure is retryable, and backs off normally.

Scheduling a digested type

A scheduled send of a type that declares a digest appends to a window at fire time. That is the correct composition of the two features rather than a special case: the replay runs the ordinary pipeline, and the ordinary pipeline routes a digested type into its window.

Events

betterPush({
  onEvent: (event) => {
    if (event.type === "notification.scheduled") {
      // { scheduledId, userId, notificationType, at }
    }
    if (event.type === "notification.canceled") {
      // { scheduledId }
    }
  },
});

Not in scope

  • No quiet hours or timezone-aware scheduling. at is an absolute instant. Per-user quiet hours need a user timezone better-push does not store.
  • No recurring or cron-style schedules. One-shot at only. BullMQ has repeatable jobs and dbQueue does not, so shipping them would break the "everything works on every backend" promise on day one. Compute the next instant yourself and schedule it when the previous one fires.

On this page