Quiet hours

Quiet hours define a daily window during which push-style channels (email, SMS, webhook) defer delivery. Inbox is unaffected — it's user-pulled, so the item is just there whenever they look.

Defers, never drops

Push channels (email, SMS, webhook) are held until the window ends. Nothing is lost.

Inbox unaffected

Inbox items deliver immediately regardless — they're user-pulled, not interruptive.

Per-recipient timezone

Each user's window resolves in their stored IANA timezone. Deploy your server anywhere.

Composable with digests

Digests batch first, quiet hours defer the result. Users wake up to summaries, not walls of emails.

Respect your users' sleep. A notification at 3am doesn't just annoy — it erodes trust. Quiet hours let you deliver the moment the window opens, without losing the notification or requiring the user to check later.

Setting quiet hours

Quiet hours are a property of the recipient. Set them at upsert time:

lib/onboard-user.ts
await notify.upsertRecipient({
  id: user.id,
  email: user.email,
  quietHours: {
    start: "22:00",
    end: "08:00",
    timezone: "America/New_York",
  },
})
Always store the user's IANA timezone. Without it, NotifyKit defaults to UTC — a user in New York with start: "22:00" would have quiet hours from 10pm UTC (6pm local), not 10pm local.

To clear quiet hours, pass quietHours: null. Omitting the field leaves the existing value unchanged.

How deferral works

1
Check window

send() checks if the current moment falls within the recipient's quiet window.

2
Schedule delivery

If inside quiet hours, a ScheduledSend row is written with scheduledFor set to the window's end time.

3
Inbox still fires

Only push channels (email, SMS, webhook) are deferred. Inbox delivers immediately — it's user-pulled.

4
Window ends — deliver

When the flush runs (automatic with setTimeoutQueue, or via flushScheduledSends()), the deferred notification sends normally.

Flushing scheduled sends

Deferred sends need something to pick them up when the window ends. The approach depends on your deployment:

EnvironmentFlush strategySetup
Long-running server + setTimeoutQueue()AutomaticNone — internal timers handle it
Long-running server + custom queueIntervalsetInterval(() => notify.flushScheduledSends(), 60_000)
Serverless (Vercel, Lambda)External cronHit an API route every minute via cron job service
app/api/cron/flush-sends/route.ts
export async function GET() {
  const flushed = await notify.flushScheduledSends()
  return Response.json({ flushed: flushed.length })
}
Safe to over-call. The flush uses an atomic claim mechanism — multiple workers (or overlapping cron invocations) can call it concurrently without duplicate delivery.

Interaction with other features

Quiet hours sit late in the pipeline — after rate limits, dedup, and preferences have already run. This ordering creates behaviors that can surprise you:

1
Rate limit (already counted)

The send consumes a rate limit slot at call time — even though delivery is deferred.

2
Preferences (already resolved)

Opted-out channels are skipped before quiet hours check. Quiet hours only defer what's left.

3
Quiet hours (you are here)

Push channels write a ScheduledSend. Inbox delivers immediately.

4
Flush → Deliver

When the window ends, the deferred send runs delivery as normal.

FeatureInteractionImplication
Required notificationsStill deferred — quiet hours override even required: trueFor true instant delivery (security alerts), don't set quiet hours on that recipient
DigestsDigest flush respects quiet hours — if it fires during the window, the rendered email is deferredUsers may get the digest at 8am even if the window was 5 minutes
Rate limitsCount at send() time, not delivery timeA burst of 50 sends at 11pm consumes 50 slots even if they all deliver at 8am
DedupDedup window runs from send() time, not delivery timeA deferred send and a re-send after the window opens won't be deduped (different keys or window expired)
Explain / dry runReports quietHours.active: true and resumesAt without side effectsUse for debugging — shows what would happen without writing a scheduled row
Rate limits count at send time. This is the most common surprise. If a user's rate limit is 20/hour and 20 notifications fire during quiet hours, they're rate-limited for that hour — even though nothing was delivered yet. Design rate limits around event frequency, not delivery frequency.

Batch delivery at window end

When the quiet window closes, every deferred notification fires at once. If 15 emails accumulated overnight, the user gets 15 emails at 8:00 AM. This is the "morning thunderstorm" — and whether it's a problem depends on volume.

Deferred countUser experienceAction needed
1–5 emailsNormal. Users expect a small batch in the morning.None — this is the designed behavior.
5–20 emailsNoisy but tolerable. Users might skim or bulk-archive.Consider adding digest to high-frequency notifications. The digest renders before quiet hours defer — so the user gets one "12 new comments" email, not twelve individual ones.
20+ emailsOverwhelming. Users unsubscribe or mark as spam.You have a noise problem, not a quiet-hours problem. Fix upstream: add rate limits, enable digests, or reduce which notifications use email.
1
Digests collapse before deferral

A 5-minute digest window batches rapid events into one email. Quiet hours then defers that single email — not the 20 individual ones.

2
Rate limits cap the total

If a notification has rateLimit: { max: 10, windowMs: 3600000 }, at most 10 emails defer per hour — even if 50 events fire.

3
Inbox absorbs the overflow

Inbox delivers immediately regardless of quiet hours. Users who check the app see everything; email is the deferred summary, not the primary channel.

Combine digests with quiet hours for high-frequency events. A notification with digest: { windowMs: 300_000 } plus a user with quiet hours from 10 PM–8 AM means: events batch into one email per 5-minute window, and any that land during quiet hours are held until morning. The user wakes up to a handful of summary emails — not a wall of individual notifications. See Digests & rate limits.

Exposing to users

Quiet hours are a recipient property — update them via upsertRecipient() from a server action tied to a settings form:

app/actions/quiet-hours.ts
"use server"
import { notify } from "@/lib/notifykit"

export async function updateQuietHours(formData: FormData) {
  const start = formData.get("start") as string
  const end = formData.get("end") as string
  const timezone = formData.get("timezone") as string
  const enabled = formData.get("enabled") === "on"

  await notify.upsertRecipient({
    id: session.userId,
    quietHours: enabled ? { start, end, timezone } : null,
  })
}
Form fieldInput typeGuidance
Enable/disable toggleCheckboxPass null to clear quiet hours entirely
Start / End time<input type="time">Native browser time picker gives HH:MM format directly
Timezone<select> with IANA zonesPre-select from Intl.DateTimeFormat().resolvedOptions().timeZone
Auto-detect timezone. On first visit, read the browser's timezone via Intl.DateTimeFormat().resolvedOptions().timeZone and pre-fill the selector. Users rarely know their IANA zone name — but their browser does.

Building the settings UI

Most apps offer quiet hours as a simple toggle with time pickers. Here's a complete form component that handles the common UX patterns — presets, auto-detected timezone, and a clear visual of the active window:

components/quiet-hours-form.tsx
"use client"
import { useState, useEffect } from "react"
import { updateQuietHours } from "@/app/actions/quiet-hours"

const PRESETS = [
  { label: "Night owl (10 PM – 8 AM)", start: "22:00", end: "08:00" },
  { label: "Sleep only (11 PM – 7 AM)", start: "23:00", end: "07:00" },
  { label: "Work focus (9 AM – 5 PM)", start: "09:00", end: "17:00" },
] as const

export function QuietHoursForm({ current }: {
  current: { start: string; end: string; timezone: string } | null
}) {
  const [enabled, setEnabled] = useState(!!current)
  const [start, setStart] = useState(current?.start ?? "22:00")
  const [end, setEnd] = useState(current?.end ?? "08:00")
  const [timezone, setTimezone] = useState(
    current?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
  )

  return (
    <form action={updateQuietHours}>
      <label className="toggle-row">
        <input
          type="checkbox"
          name="enabled"
          checked={enabled}
          onChange={(e) => setEnabled(e.target.checked)}
        />
        <strong>Enable quiet hours</strong>
        <span className="hint">Pause email & SMS during this window</span>
      </label>

      <fieldset disabled={!enabled}>
        {/* Presets */}
        <div className="presets" role="group" aria-label="Quick presets">
          {PRESETS.map((p) => (
            <button
              key={p.label}
              type="button"
              className={start === p.start && end === p.end ? "active" : ""}
              onClick={() => { setStart(p.start); setEnd(p.end) }}
            >
              {p.label}
            </button>
          ))}
        </div>

        {/* Custom time pickers */}
        <div className="time-row">
          <label>
            From
            <input type="time" name="start" value={start} onChange={e => setStart(e.target.value)} />
          </label>
          <label>
            Until
            <input type="time" name="end" value={end} onChange={e => setEnd(e.target.value)} />
          </label>
        </div>

        {/* Timezone */}
        <label>
          Timezone
          <select name="timezone" value={timezone} onChange={e => setTimezone(e.target.value)}>
            {Intl.supportedValuesOf("timeZone").map(tz => (
              <option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
            ))}
          </select>
        </label>
      </fieldset>

      <button type="submit">Save</button>
    </form>
  )
}
UX patternWhy
Presets first, custom belowMost users pick "Night owl" and never touch the time pickers. One click vs four inputs.
Auto-detect timezoneUsers don't know their IANA zone. Intl.DateTimeFormat() gets it from the browser.
Disable fieldset when offGrey out all inputs when quiet hours are toggled off — clear visual that nothing applies.
"Email & SMS" hintUsers need to know inbox notifications still arrive. Without this, they expect total silence.
Offer presets, not just raw pickers. In user research, most people choose one of 2–3 common patterns. A preset row with one-click options has higher adoption than a bare time input — and fewer support tickets from users who accidentally set 10 AM instead of 10 PM.

Timezone edge cases

Quiet hours use wall-clock time in the recipient's timezone. This means DST transitions, travel, and missing timezones can cause unexpected behavior:

ScenarioWhat happensRecommendation
Spring forward (clock skips 2:00→3:00)If quiet hours end at 2:30, the window ends at 3:00 — the 2:30 wall-clock never existsUse round hours (e.g. 22:00–08:00) to avoid landing in the skipped gap
Fall back (clock repeats 1:00–2:00)NotifyKit uses the first occurrence — quiet hours end on time, not an hour lateNo action needed. The window resolves correctly.
User travels (timezone changes)Quiet hours fire in the stored timezone, not the user's current locationPrompt users to update timezone in settings, or auto-detect on each session
No timezone storedFalls back to UTC — quiet hours may be completely wrong for the userAlways set timezone. If unknown, detect from the browser before enabling.
Server in different timezoneNo effect — NotifyKit uses the stored IANA zone, not the server's local timeNo action needed. Deploy anywhere.
lib/sync-timezone.ts
async function syncTimezone() {
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
  await fetch("/api/notifykit-actions/update-timezone", {
    method: "POST",
    body: JSON.stringify({ timezone: tz }),
  })
}
app/actions/update-timezone.ts
export async function updateTimezone(timezone: string) {
  const recipient = await notify.getRecipient(session.userId)
  if (recipient?.quietHours && recipient.quietHours.timezone !== timezone) {
    await notify.upsertRecipient({
      id: session.userId,
      quietHours: { ...recipient.quietHours, timezone },
    })
  }
}
Don't use fixed UTC offsets. Storing "UTC-5" instead of "America/New_York" means quiet hours won't adjust when DST changes. Always use IANA zone names — they encode the full history of a region's UTC offset transitions.

Testing quiet hours in development

You can't wait until 10pm to verify your quiet hours work. Use these patterns to test deterministically:

ApproachHowBest for
Set a window that's always activestart: "00:00", end: "23:59"Quick manual testing — every send is deferred
Use a far-away timezoneSet timezone to one where it's currently nighttimeTesting timezone resolution without changing your system clock
Use explain() to checkexplanation.quietHours.active tells you without side effectsAutomated tests — assert on the explanation without triggering delivery
Send then flush immediatelysend()flushScheduledSends()Integration tests — verify the full defer→flush→deliver cycle
tests/quiet-hours.test.ts
import { describe, it, expect } from "vitest"

describe("quiet hours", () => {
  it("defers email during quiet window", async () => {
    // Set up a recipient with an always-active quiet window
    await testNotify.upsertRecipient({
      id: "user_1",
      email: "test@example.com",
      quietHours: { start: "00:00", end: "23:59", timezone: "UTC" },
    })

    const result = await testNotify.send({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    // Email was deferred, not delivered
    expect(result.deferredChannels).toContain("email")
    expect(result.deliveries.filter(d => d.channel === "email")).toHaveLength(0)

    // Inbox still delivered immediately (pull channel)
    expect(result.inboxItems).toHaveLength(1)
  })

  it("flushes deferred sends", async () => {
    // After the window "ends" (or in tests, just flush manually)
    const flushed = await testNotify.flushScheduledSends()

    expect(flushed).toHaveLength(1)
    expect(flushed[0].deliveries[0].channel).toBe("email")
    expect(flushed[0].deliveries[0].status).toBe("sent")
  })

  it("explain() reports quiet hours without side effects", async () => {
    const explanation = await testNotify.explain({
      recipientId: "user_1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    expect(explanation.quietHours.active).toBe(true)
    // No records written, no deliveries queued
  })
})
Pro tip: use start: "00:00", end: "23:59" in test fixtures. This creates an always-active window regardless of when your CI runs. For the inverse (never active), simply omit quietHours or set it to null.

Monitoring scheduled sends

The most dangerous quiet-hours bug isn't a wrong window — it's a flush that stops running. Deferred notifications pile up silently, and users never receive them. Detect this before they file a ticket.

Failure modeHow it happensUser impact
Cron stops firingVercel cron limit exceeded, Lambda disabled, server restarted without cron re-registrationAll deferred emails/SMS stuck indefinitely — users never get them
Flush errors silentlyDatabase connection fails inside flushScheduledSends(), no error reporting configuredSame as above — sends claimed but not delivered
Clock skew across workersMulti-instance deploy where one worker's clock drifts >1 minuteSome sends flush early or late — subtle timing issues

Health check pattern

Query the scheduled sends table directly. If rows exist with a scheduledFor in the past (more than a few minutes ago), the flush isn't keeping up:

app/api/health/quiet-hours/route.ts
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { lt, count } from "drizzle-orm"
import { db } from "@/lib/db"

export async function GET() {
  const fiveMinAgo = new Date(Date.now() - 5 * 60_000)

  const [{ stale }] = await db
    .select({ stale: count() })
    .from(notifyKitSchema.scheduledSends)
    .where(lt(notifyKitSchema.scheduledSends.scheduledFor, fiveMinAgo))

  if (stale > 0) {
    return Response.json(
      { status: "unhealthy", staleSends: stale, message: "Flush cron may have stopped" },
      { status: 503 }
    )
  }

  return Response.json({ status: "healthy", staleSends: 0 })
}
Alert conditionSeverityAction
staleSends > 0 for > 5 minWarningCheck cron job status — might be delayed, not dead
staleSends > 0 for > 30 minCriticalFlush is dead. Manually call flushScheduledSends() and fix the cron.
staleSends > 100CriticalLarge backlog — flush may be running but failing. Check error logs.
Wire the health check into your uptime monitor. Point Datadog, Better Uptime, or a simple cron at /api/health/quiet-hours. Alert when it returns 503. This catches the silent failure that no other test will — a broken cron produces no errors, just absence of delivery.

Flush observability hook

Track every flush invocation so you can confirm it's running and measure how many sends it processes:

app/api/cron/flush-sends/route.ts
export async function GET() {
  const start = Date.now()
  const flushed = await notify.flushScheduledSends()
  const durationMs = Date.now() - start

  metrics.gauge("notifykit.flush.duration_ms", durationMs)
  metrics.gauge("notifykit.flush.count", flushed.length)

  if (durationMs > 5000) {
    logger.warn("Quiet hours flush took >5s", { durationMs, count: flushed.length })
  }

  const failed = flushed.filter(r => r.deliveries.some(d => d.status === "failed"))
  if (failed.length > 0) {
    metrics.inc("notifykit.flush.failures", failed.length)
  }

  return Response.json({ flushed: flushed.length, durationMs })
}
A flush that returns zero isn't broken. Zero means no sends were due — normal outside quiet-hours windows. Only alert on the health check (stale rows exist) — not on the flush count being zero.

Quiet hours vs digests

Both reduce notification noise, but they solve different problems. Understanding the distinction prevents choosing the wrong tool:

Quiet hoursDigests
Problem solvedDon't interrupt during sleep/focus timeCollapse many events into one notification
Controlled byRecipient (their schedule)Developer (notification config)
Notification countSame number, just delayedFewer — many become one
ContentOriginal notification, unchangedCombined via render() function
TimingSends at window end (e.g. 8am)Sends after window expires (e.g. after 5 min of quiet)
Channels affectedPush only (email, SMS, webhook). Inbox unaffected.All channels (the digest is the notification)

Use quiet hours when...

The notification is time-sensitive during waking hours but shouldn't wake someone up. Each event is individually important.

Use digests when...

Individual events are low-value but a summary is useful. "5 new comments" is better than 5 separate emails.

Use both when...

High-frequency events that also shouldn't interrupt sleep. Digest collapses them, quiet hours delays delivery of the digest.

ScenarioRight toolWhy
Team invite at 2amQuiet hoursOne important notification — just delay it until morning
15 likes on a post in 10 minutesDigestCollapse into "15 people liked your post" — the individual events don't matter
Comment thread exploding while user sleepsBothDigest collapses "42 comments" into one email, quiet hours holds it until 8am
Security alert at 3amNeitherDon't configure quiet hours for these recipients — required: true prevents opt-out but doesn't bypass the quiet window
Quiet hours defer even required: true notifications. The required flag only prevents users from opting out via preferences — it does not bypass the quiet window. Required notifications are still held until the window ends. For instant delivery regardless of time (security alerts, 2FA codes), don't configure quiet hours on those recipients.