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.
Setting quiet hours
Quiet hours are a property of the recipient. Set them at upsert time:
await notify.upsertRecipient({
id: user.id,
email: user.email,
quietHours: {
start: "22:00",
end: "08:00",
timezone: "America/New_York",
},
})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
send() checks if the current moment falls within the recipient's quiet window.
If inside quiet hours, a ScheduledSend row is written with scheduledFor set to the window's end time.
Only push channels (email, SMS, webhook) are deferred. Inbox delivers immediately — it's user-pulled.
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:
| Environment | Flush strategy | Setup |
|---|---|---|
Long-running server + setTimeoutQueue() | Automatic | None — internal timers handle it |
| Long-running server + custom queue | Interval | setInterval(() => notify.flushScheduledSends(), 60_000) |
| Serverless (Vercel, Lambda) | External cron | Hit an API route every minute via cron job service |
export async function GET() {
const flushed = await notify.flushScheduledSends()
return Response.json({ flushed: flushed.length })
}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:
The send consumes a rate limit slot at call time — even though delivery is deferred.
Opted-out channels are skipped before quiet hours check. Quiet hours only defer what's left.
Push channels write a ScheduledSend. Inbox delivers immediately.
When the window ends, the deferred send runs delivery as normal.
| Feature | Interaction | Implication |
|---|---|---|
| Required notifications | Still deferred — quiet hours override even required: true | For true instant delivery (security alerts), don't set quiet hours on that recipient |
| Digests | Digest flush respects quiet hours — if it fires during the window, the rendered email is deferred | Users may get the digest at 8am even if the window was 5 minutes |
| Rate limits | Count at send() time, not delivery time | A burst of 50 sends at 11pm consumes 50 slots even if they all deliver at 8am |
| Dedup | Dedup window runs from send() time, not delivery time | A deferred send and a re-send after the window opens won't be deduped (different keys or window expired) |
| Explain / dry run | Reports quietHours.active: true and resumesAt without side effects | Use for debugging — shows what would happen without writing a scheduled row |
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 count | User experience | Action needed |
|---|---|---|
| 1–5 emails | Normal. Users expect a small batch in the morning. | None — this is the designed behavior. |
| 5–20 emails | Noisy 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+ emails | Overwhelming. 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. |
A 5-minute digest window batches rapid events into one email. Quiet hours then defers that single email — not the 20 individual ones.
If a notification has rateLimit: { max: 10, windowMs: 3600000 }, at most 10 emails defer per hour — even if 50 events fire.
Inbox delivers immediately regardless of quiet hours. Users who check the app see everything; email is the deferred summary, not the primary channel.
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:
"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 field | Input type | Guidance |
|---|---|---|
| Enable/disable toggle | Checkbox | Pass 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 zones | Pre-select from Intl.DateTimeFormat().resolvedOptions().timeZone |
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:
"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 pattern | Why |
|---|---|
| Presets first, custom below | Most users pick "Night owl" and never touch the time pickers. One click vs four inputs. |
| Auto-detect timezone | Users don't know their IANA zone. Intl.DateTimeFormat() gets it from the browser. |
| Disable fieldset when off | Grey out all inputs when quiet hours are toggled off — clear visual that nothing applies. |
| "Email & SMS" hint | Users need to know inbox notifications still arrive. Without this, they expect total silence. |
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:
| Scenario | What happens | Recommendation |
|---|---|---|
| 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 exists | Use 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 late | No action needed. The window resolves correctly. |
| User travels (timezone changes) | Quiet hours fire in the stored timezone, not the user's current location | Prompt users to update timezone in settings, or auto-detect on each session |
| No timezone stored | Falls back to UTC — quiet hours may be completely wrong for the user | Always set timezone. If unknown, detect from the browser before enabling. |
| Server in different timezone | No effect — NotifyKit uses the stored IANA zone, not the server's local time | No action needed. Deploy anywhere. |
async function syncTimezone() {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
await fetch("/api/notifykit-actions/update-timezone", {
method: "POST",
body: JSON.stringify({ timezone: tz }),
})
}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 },
})
}
}"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:
| Approach | How | Best for |
|---|---|---|
| Set a window that's always active | start: "00:00", end: "23:59" | Quick manual testing — every send is deferred |
| Use a far-away timezone | Set timezone to one where it's currently nighttime | Testing timezone resolution without changing your system clock |
| Use explain() to check | explanation.quietHours.active tells you without side effects | Automated tests — assert on the explanation without triggering delivery |
| Send then flush immediately | send() → flushScheduledSends() | Integration tests — verify the full defer→flush→deliver cycle |
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
})
})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 mode | How it happens | User impact |
|---|---|---|
| Cron stops firing | Vercel cron limit exceeded, Lambda disabled, server restarted without cron re-registration | All deferred emails/SMS stuck indefinitely — users never get them |
| Flush errors silently | Database connection fails inside flushScheduledSends(), no error reporting configured | Same as above — sends claimed but not delivered |
| Clock skew across workers | Multi-instance deploy where one worker's clock drifts >1 minute | Some 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:
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 condition | Severity | Action |
|---|---|---|
staleSends > 0 for > 5 min | Warning | Check cron job status — might be delayed, not dead |
staleSends > 0 for > 30 min | Critical | Flush is dead. Manually call flushScheduledSends() and fix the cron. |
staleSends > 100 | Critical | Large backlog — flush may be running but failing. Check error logs. |
/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:
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 })
}Quiet hours vs digests
Both reduce notification noise, but they solve different problems. Understanding the distinction prevents choosing the wrong tool:
| Quiet hours | Digests | |
|---|---|---|
| Problem solved | Don't interrupt during sleep/focus time | Collapse many events into one notification |
| Controlled by | Recipient (their schedule) | Developer (notification config) |
| Notification count | Same number, just delayed | Fewer — many become one |
| Content | Original notification, unchanged | Combined via render() function |
| Timing | Sends at window end (e.g. 8am) | Sends after window expires (e.g. after 5 min of quiet) |
| Channels affected | Push 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.
| Scenario | Right tool | Why |
|---|---|---|
| Team invite at 2am | Quiet hours | One important notification — just delay it until morning |
| 15 likes on a post in 10 minutes | Digest | Collapse into "15 people liked your post" — the individual events don't matter |
| Comment thread exploding while user sleeps | Both | Digest collapses "42 comments" into one email, quiet hours holds it until 8am |
| Security alert at 3am | Neither | Don't configure quiet hours for these recipients — required: true prevents opt-out but doesn't bypass the quiet window |
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.