Defining notifications
A notification has an id, a payload schema, and a list of channels. Define them in code. Your notification IDs and payload shapes become typed values the rest of your app can rely on.
Identifier
A unique string like "comment_mentioned". Used in send(), preferences, and rate limit scoping.
Payload
A typed schema of the data this notification needs. Enforced at compile time and validated at runtime.
Channels
Where to deliver — inbox, email, SMS, webhook. Each channel has its own template using {{payload}} interpolation.
Basic definition
import { channel, notification } from "@notifykitjs/core"
const inbox = channel.inbox()
const email = channel.email()
export const commentMentioned = notification({
id: "comment_mentioned",
payload: {
actorName: "string",
postTitle: "string",
postUrl: "string",
},
channels: [
inbox({
title: "{{actorName}} mentioned you",
body: "In {{postTitle}}",
actionUrl: "{{postUrl}}",
}),
email({
subject: "{{actorName}} mentioned you in {{postTitle}}",
body: "Open {{postUrl}} to reply.",
}),
],
})Payload schema
| Approach | When to use | Provides |
|---|---|---|
| Inline schema | Flat payloads with simple types | Compile-time types + runtime type check |
| Zod / Valibot / ArkType | Nested objects, arrays, refinements (UUID, positive, min/max) | Full schema validation + compile-time types |
Inline schema
A Record<string, "string" | "number" | "boolean"> — simple, zero dependencies:
notification({
id: "order_shipped",
payload: {
orderNumber: "string",
total: "number",
expedited: "boolean",
},
channels: [...],
})Zod validation
For complex shapes, use zodPayload() to get both the schema definition and a runtime validator:
import { z } from "zod"
import { zodPayload } from "@notifykitjs/core/zod"
const invoicePayload = zodPayload(z.object({
invoiceId: z.string().uuid(),
amount: z.number().positive(),
}))
notification({
id: "invoice_created",
payload: invoicePayload.payload,
validate: invoicePayload.validate,
channels: [...],
})send(). If the payload doesn't match, the send short-circuits with invalid_payload — no delivery attempts, no records written. Check result.skipped or use explain() to debug.Templates
Every channel's string fields support {{key}} interpolation against the payload:
| Template | Payload | Result |
|---|---|---|
{{actorName}} mentioned you | { actorName: "Rey" } | Rey mentioned you |
In {{postTitle}} | { postTitle: "Launch" } | In Launch |
{{missing}} | {} | (empty string) |
body gets {{_unsubscribeUrl}} injected automatically when unsubscribe is configured. See Preferences & unsubscribe.Optional fields
Beyond the three required fields, notifications accept configuration for metadata, behavior, and delivery control:
| Field | Purpose | When to use |
|---|---|---|
description | Human label for admin UIs | Always — helps non-engineers understand the notification |
category | Groups notifications in preference UIs | When you have 5+ notifications that need grouping |
required | Bypasses preference checks | Transactional: password resets, 2FA, billing receipts |
defaultChannels | Override which channels are on by default | When a notification shouldn't email by default |
redact | Mask fields in logs and hooks | Payload contains PII (emails, IPs, names) |
rateLimit | Hard-cap sends per window | Preventing notification spam |
digest | Batch sends into one delivery | High-frequency events (comments, likes) |
fallback | Catch failed or skipped channels | Critical notifications that must reach the user |
notification({
id: "team_invite",
payload: { inviterName: "string", teamName: "string" },
channels: [...],
description: "Sent when a user is invited to a team",
category: "social",
required: true,
redact: ["inviterName"],
rateLimit: { max: 5, windowMs: 60_000 },
fallback: inbox({ title: "You have a team invite" }),
})Which options do I need?
Walk through these questions for each new notification. Most only need the first two — add complexity when you observe problems, not upfront.
Must it always deliver?
Password resets, 2FA, receipts → set required: true. This bypasses user preference opt-outs.
Can the same event fire rapidly?
Multiple likes, edits, or comments in seconds → add digest with a window (e.g. 5 min). Users get one email, not twenty.
Could a bad actor trigger it in a loop?
Any user-initiated event that targets another user → add rateLimit. Caps sends per recipient per window.
Is delivery failure unacceptable?
Critical alerts where the user must see it → add fallback to a secondary channel (usually inbox).
| Scenario | Options to set | Example notification |
|---|---|---|
| Simple activity alert | None — defaults are fine | Task assigned, team invite |
| Transactional email | required: true | Password reset, payment receipt |
| High-frequency social | digest + rateLimit | Post liked, new follower |
| Critical system alert | rateLimit + fallback | Usage limit warning, incident alert |
| Noisy + critical | digest + rateLimit + fallback | Error spike alert, security events |
id, payload, and channels. Add rateLimit when you see spam, digest when users complain about noise, and fallback for paths where delivery failures cause support tickets.Modeling your notifications
Not sure what notifications to create? Start by listing every event where a user should know something happened. Then categorize:
| Category | Examples | Typical config |
|---|---|---|
| Transactional | Password reset, 2FA code, payment receipt | required: true, email only, no digest |
| Activity | Comment mention, team invite, task assigned | Inbox + email, dedup by entity, user can opt out |
| Social | New follower, post liked, reaction added | Inbox + digest (batch by window), email off by default |
| System | Deploy succeeded, usage limit warning, incident alert | Webhook + inbox, rate-limited, with fallback |
comment_created_inbox and comment_created_email separately — use one definition with multiple channels. The user controls which channels fire via preferences.Payload design patterns
The payload is the contract between your send() call and your channel templates. Structure it around what the recipient needs to understand — not around your internal data model.
Actor + target
Someone did something to something. Comments, mentions, reviews, assignments.
Status change
Something changed state. Orders, deployments, approvals, workflow transitions.
Content preview
Someone created content you should see. Messages, posts, document edits.
Threshold alert
A metric crossed a boundary. Usage limits, latency spikes, budget caps.
Action required
The recipient must do something by a deadline. Approvals, reviews, renewals.
| Pattern | Shape | Use for | Template example |
|---|---|---|---|
| Actor + target | actorName, targetName, targetUrl | Someone did something to something | {{actorName}} commented on {{targetName}} |
| Status change | entityName, oldStatus, newStatus, entityUrl | Something changed state | {{entityName}} moved to {{newStatus}} |
| Content preview | actorName, preview, contentUrl | Someone created content you should see | {{actorName}}: "{{preview}}" |
| Threshold alert | metricName, currentValue, threshold, dashboardUrl | A metric crossed a boundary | {{metricName}} hit {{currentValue}} (limit: {{threshold}}) |
| Action required | actionLabel, deadline, actionUrl | Recipient must do something by a date | Action needed: {{actionLabel}} by {{deadline}} |
// Actor + target: "Rey commented on Launch Plan"
notification({
id: "comment_added",
payload: {
actorName: "string",
targetName: "string",
targetUrl: "string",
preview: "string",
},
channels: [
inbox({
title: "{{actorName}} commented on {{targetName}}",
body: "{{preview}}",
actionUrl: "{{targetUrl}}",
}),
],
})
// Status change: "Order #1234 shipped"
notification({
id: "order_status_changed",
payload: {
orderNumber: "string",
newStatus: "string",
trackingUrl: "string",
},
channels: [
inbox({
title: "Order #{{orderNumber}} {{newStatus}}",
actionUrl: "{{trackingUrl}}",
}),
email({
subject: "Your order #{{orderNumber}} is now {{newStatus}}",
body: "Track your order: {{trackingUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
}),
],
})
// Threshold alert: "API latency hit 2400ms (limit: 2000ms)"
notification({
id: "metric_threshold",
payload: {
metricName: "string",
currentValue: "string",
threshold: "string",
dashboardUrl: "string",
},
channels: [
inbox({
title: "{{metricName}} exceeded threshold",
body: "Current: {{currentValue}} (limit: {{threshold}})",
actionUrl: "{{dashboardUrl}}",
}),
],
})actionUrl / targetUrl / dashboardUrl gives the user somewhere to go. Without it, the notification tells them something happened but doesn't help them act on it.Payload field naming conventions
| Convention | Example | Why |
|---|---|---|
| Use display-ready values | actorName: "Rey Saluja" not actorId: "usr_123" | Templates render directly — no lookup possible at render time |
| Include URLs, not entity IDs | postUrl: "/posts/42" not postId: "42" | Channels need a link destination, not a raw identifier |
Use string for numbers in templates | amount: "string" → pass "$12.99" | Formatting (currency, locale) should happen at send time, not in the template |
| Keep payloads flat | actorName, actorAvatar not actor: { name, avatar } | Inline schemas only support flat keys. Nested requires Zod. |
| Prefix internal fields with context | invoiceId not just id | Avoid collisions when payloads are logged or composed in digests |
actorId: "usr_123" without actorName, your templates can't show a human-readable name. render() functions can't be async — they can't look up names from a database. Resolve everything at send time.Complete example
A production notification combining multiple features — rate limiting, digest, fallback, category, and redaction — all in one definition:
export const commentMentioned = notification({
id: "comment_mentioned",
description: "Someone mentioned you in a comment",
category: "activity",
payload: {
actorName: "string",
actorEmail: "string",
postTitle: "string",
postUrl: "string",
commentPreview: "string",
},
channels: [
inbox({
title: "{{actorName}} mentioned you",
body: "{{commentPreview}}",
actionUrl: "{{postUrl}}",
}),
email({
subject: "{{actorName}} mentioned you in {{postTitle}}",
body: "{{commentPreview}}\n\nOpen {{postUrl}} to reply.\n\nUnsubscribe: {{_unsubscribeUrl}}",
}),
],
// Don't spam — max 30 mentions per hour per recipient
rateLimit: { max: 30, windowMs: 60 * 60_000 },
// Batch rapid-fire mentions into one email
digest: {
windowMs: 5 * 60_000,
key: ({ payload }) => payload.postUrl,
render: ({ payloads, count }) => ({
actorName: payloads[payloads.length - 1]!.actorName,
actorEmail: payloads[payloads.length - 1]!.actorEmail,
postTitle: payloads[0]!.postTitle,
postUrl: payloads[0]!.postUrl,
commentPreview: count > 1
? `${count} new mentions`
: payloads[0]!.commentPreview,
}),
},
// If email fails, at least show it in the inbox
fallback: inbox({
title: "{{actorName}} mentioned you (email delivery failed)",
actionUrl: "{{postUrl}}",
}),
// Strip PII from logs and timeline
redact: ["actorEmail"],
})| Feature used | Why |
|---|---|
category | Groups with other activity notifications in the preferences UI |
rateLimit | Prevents a spam attack from flooding a user's inbox |
digest | If Rey mentions you 5 times in 5 minutes, you get one email, not five |
fallback | Email delivery can fail — the user still sees it in-app |
redact | Actor email appears in logs as [REDACTED] — only needed for delivery |
id, payload, and channels. Add rate limiting when you observe spam, digests when users complain about noise, and fallbacks for critical paths. Don't over-engineer on day one.Evolving definitions over time
Notification definitions live in code and evolve with your app. Use this quick matrix to assess changes before you deploy:
Safe — deploy freely
Change template text, add an optional payload field, add or remove a channel, tweak rateLimit / digest values.
Risky — review first
Remove a payload field (search all send() sites), delete a notification (wait for in-flight to drain).
Breaking — requires migration
Rename a notification ID (preferences, dedup keys, and rate limit counters reference the old one).
| Change | Safe? | Risk | Mitigation |
|---|---|---|---|
| Add a payload field | Yes (if optional) | Old sends missing the field render {{newField}} as empty | Use a default in your template: {{newField}} renders blank gracefully, or use render() with a fallback |
| Remove a payload field | Risky | Old templates referencing removed field render empty; callers passing it get a TS error | Remove from schema and template in the same deploy. Search for all send() calls first. |
| Rename a notification ID | Breaking | Existing preferences, rate limit counters, and dedup keys reference the old ID | Create a new ID, migrate preferences with a script, then remove the old one |
| Change a template string | Yes | Existing inbox items keep the old rendered text (it's stored at write time) | No migration needed — only new sends use the updated template |
| Add a channel | Yes | Users who didn't opt out will start receiving it immediately | Set defaultChannels: { newChannel: false } to make it opt-in at first |
| Remove a channel | Yes | Users who had preferences set for that channel keep stale preference rows | Harmless — stale preferences are ignored. Clean up optionally. |
| Delete a notification entirely | Careful | Inbox items for old sends still exist; preferences become orphaned | Remove from the array. Old inbox items remain visible (they're just data). Clean up if needed. |
Adding fields safely
// BEFORE: original definition
notification({
id: "comment_mentioned",
payload: { actorName: "string", postUrl: "string" },
channels: [
inbox({ title: "{{actorName}} mentioned you" }),
],
})
// AFTER: added commentPreview (optional in template)
notification({
id: "comment_mentioned",
payload: { actorName: "string", postUrl: "string", commentPreview: "string" },
channels: [
inbox({
title: "{{actorName}} mentioned you",
body: "{{commentPreview}}", // renders empty if old sends didn't have it
}),
],
})Renaming a notification (migration)
// Step 1: Create the new definition alongside the old one
export const commentReply = notification({
id: "comment_reply", // new name
payload: { actorName: "string", postUrl: "string", commentPreview: "string" },
channels: [...],
})
// Step 2: Migrate preferences from old ID to new ID
const allPrefs = await db
.select()
.from(notifyKitSchema.preferences)
.where(eq(notifyKitSchema.preferences.notificationId, "comment_mentioned"))
for (const pref of allPrefs) {
await notify.preferences.update({
recipientId: pref.recipientId,
notificationId: "comment_reply",
channels: pref.channels,
})
}
// Step 3: Update all send() call sites to use the new ID
// Step 4: Remove the old definition from the notifications arrayDeprecating gracefully
When removing a notification, consider existing subscribers and in-flight state:
Remove all send() calls for this notification from your code.
If using digests or quiet hours, wait for flushDigests() and flushScheduledSends() to clear buffered sends.
Delete from the notifications array. The preferences UI stops showing it.
Delete orphaned preferences and old inbox items if they clutter your database.
"comment_mentioned" and later create a new notification with the same ID, old preference rows will apply to the new notification — users who opted out of the old one will be opted out of the new one. Use a fresh ID.Registering definitions
Pass all notification definitions to createNotifyKit(). The as const assertion is required for full type inference:
import { createNotifyKit } from "@notifykitjs/core"
import { commentMentioned } from "./notifications/comment-mentioned"
import { orderShipped } from "./notifications/order-shipped"
import { teamInvite } from "./notifications/team-invite"
export const notify = createNotifyKit({
notifications: [commentMentioned, orderShipped, teamInvite] as const,
// ...
})Now notify.send() only accepts valid notification IDs and the correct payload shape for each.
Safe schema evolution
Once a notification is in production, changing its payload schema requires care. Buffered digests, queued deliveries, and in-flight sends may still carry the old shape. Follow these rules to evolve safely:
| Change | Safe? | Why | Migration needed |
|---|---|---|---|
| Add an optional field | Yes | Old sends pass validation (field is optional). Templates render it as empty string if absent. | None |
| Add a required field | No | In-flight sends and buffered digests lack the field — validation fails on flush. | Add as optional first, backfill all callers, then make required in a follow-up deploy |
| Remove a field | Yes (if templates updated) | Old queued sends may still include it (harmless — extra fields are ignored). Danger is templates referencing a now-missing field. | Remove from templates first, then from schema |
| Rename a field | No | Equivalent to adding required + removing old. In-flight sends break. | Add new field (optional), update callers, update templates, remove old field |
| Change a field's type | No | Old sends carry the old type — validation or template rendering breaks. | Add a new field with the new type, migrate callers, then remove the old field |
render() function receives payloads that lack it. Always guard with optional chaining or defaults inside render().Safe field addition (two-deploy pattern)
// Deploy 1: add as optional, handle absence in templates
notification({
id: "order_shipped",
payload: {
orderNumber: "string",
trackingUrl: "string",
carrier: "string?", // new optional field
},
channels: [
inbox({
render: (p) => ({
title: `Order ${p.orderNumber} shipped`,
body: p.carrier
? `Shipped via ${p.carrier}`
: "Your order is on the way",
}),
}),
],
digest: {
windowMs: 60 * 60_000,
render: ({ payloads, count }) => ({
orderNumber: payloads[payloads.length - 1]!.orderNumber,
trackingUrl: payloads[payloads.length - 1]!.trackingUrl,
carrier: payloads[payloads.length - 1]?.carrier ?? undefined,
}),
},
})
// Deploy 2 (after all callers pass carrier): promote to required
// payload: { orderNumber: "string", trackingUrl: "string", carrier: "string" }Schema accepts both old (without field) and new (with field) payloads. Templates handle absence gracefully.
Every send() call now passes the new field. Verify with grep — no caller should omit it.
Wait at least as long as your longest digest window + queue retry delay. All old-shape payloads flush.
Now safe — no in-flight payloads lack the field. TypeScript enforces it at compile time from here on.
setTimeoutQueue() with 5 retry attempts at exponential backoff, add ~30 seconds for the retry tail. For inlineQueue(), there's no wait — sends resolve synchronously.File organization
One notification per file keeps things manageable as your count grows. Scale your structure to match your notification count:
| Scale | Pattern | File structure |
|---|---|---|
| 1–5 notifications | All in one file | lib/notifykit.ts — definitions + instance together |
| 5–20 notifications | One file per notification | lib/notifications/comment-mentioned.ts, order-shipped.ts, etc. |
| 20+ notifications | Grouped by domain + barrel | lib/notifications/activity/, billing/, social/ with index.ts per folder |
// lib/notifications/index.ts — barrel export for all definitions
export { commentMentioned } from "./activity/comment-mentioned"
export { taskAssigned } from "./activity/task-assigned"
export { newFollower } from "./social/new-follower"
export { invoicePaid } from "./billing/invoice-paid"
export { passwordReset } from "./billing/password-reset"
// lib/notifykit.ts — single import, wire up
import * as notifications from "./notifications"
export const notify = createNotifyKit({
notifications: Object.values(notifications) as const,
database: drizzlePostgresAdapter(db),
providers: { email: resendProvider({ ... }) },
})Shared channel builders
When multiple notifications share the same email structure or inbox format, extract a builder to avoid repetition:
// lib/notifications/_shared.ts
import { channel } from "@notifykitjs/core"
const inbox = channel.inbox()
const email = channel.email()
export function actorInbox(template: { action: string; target: string }) {
return inbox({
title: `{{actorName}} ${template.action}`,
body: `In ${template.target}`,
actionUrl: "{{actionUrl}}",
})
}
export function transactionalEmail(subject: string) {
return email({
subject,
body: `${subject}\n\n{{body}}\n\n---\nUnsubscribe: {{_unsubscribeUrl}}`,
})
}
// lib/notifications/comment-mentioned.ts
import { notification } from "@notifykitjs/core"
import { actorInbox, transactionalEmail } from "./_shared"
export const commentMentioned = notification({
id: "comment_mentioned",
payload: { actorName: "string", postTitle: "string", actionUrl: "string", body: "string" },
channels: [
actorInbox({ action: "mentioned you", target: "{{postTitle}}" }),
transactionalEmail("{{actorName}} mentioned you in {{postTitle}}"),
],
})When to split vs combine
A common question: should "comment mentioned" and "comment replied" be one notification or two? Use this decision framework:
| Question | If yes → split | If no → combine |
|---|---|---|
| Do users want separate preference toggles? | "Mentions" and "Replies" as separate rows in settings | One "Comments" toggle covers both |
| Are the payloads meaningfully different? | A mention has postUrl, a deploy has buildId + logs | Both carry actorName + targetUrl |
| Would they have different rate limits or digests? | Mentions digest at 5min, replies deliver immediately | Same noise profile, same delivery rules |
| Do different channels apply? | Mentions → inbox + email, deploys → webhook only | Both go to inbox + email |
Testing your definitions
Notification definitions are the contract between your app and your users. Test them the same way you'd test any API contract — verify that the right channels fire, templates render correctly, and invalid payloads are rejected.
| What to test | Why | Catches |
|---|---|---|
| Template rendering | Verifies payload fields appear where expected | Typos in {{field}} names, missing fields rendering blank |
| Channel selection | Confirms the right channels fire for each notification | Accidentally removing a channel, or adding one users don't expect |
| Payload validation | Ensures malformed data is rejected at send time | Missing required fields, wrong types, schema drift |
| Digest rendering | Confirms batched payloads merge into a coherent message | Broken render() when count is 1 vs many, undefined access |
| Rate limit behavior | Proves the cap is enforced at the expected threshold | Rate limit misconfigured (too high or too low) |
Pattern: definition smoke tests
import { describe, it, expect } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { commentMentioned, orderShipped } from "./notifications"
function setup() {
return createNotifyKit({
notifications: [commentMentioned, orderShipped] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
}
describe("notification definitions", () => {
it("comment_mentioned renders actor name in inbox title", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
const result = await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
})
expect(result.inboxItems[0].title).toBe("Rey mentioned you")
expect(result.inboxItems[0].body).toBe("In Launch")
expect(result.inboxItems[0].actionUrl).toBe("/p/1")
})
it("comment_mentioned delivers to both inbox and email", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
const result = await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
})
expect(result.inboxItems).toHaveLength(1)
expect(result.deliveries.find(d => d.channel === "email")).toBeDefined()
})
it("rejects invalid payload (missing required field)", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
const result = await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
// @ts-expect-error — testing runtime validation
payload: { actorName: "Rey" },
})
expect(result.skipped).toBe(true)
expect(result.skipReason).toBe("invalid_payload")
})
})Testing digest rendering
Digests are the trickiest part of a definition to get right — the render() function must handle both the single-event and multi-event case gracefully:
describe("comment_mentioned digest", () => {
it("renders single event as normal message", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1", commentPreview: "looks good!" },
})
// Flush the digest window
const flushed = await notify.flushDigests()
expect(flushed[0].inboxItems[0].body).toBe("looks good!")
})
it("renders multiple events as count summary", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
// Send 3 events within the digest window
for (const actor of ["Rey", "Sam", "Alex"]) {
await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: actor, postTitle: "Launch", postUrl: "/p/1", commentPreview: "..." },
})
}
const flushed = await notify.flushDigests()
expect(flushed[0].inboxItems[0].body).toBe("3 new mentions")
})
})Testing rate limits on definitions
describe("comment_mentioned rate limit", () => {
it("enforces max 30 sends per hour", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
// Send up to the limit
for (let i = 0; i < 30; i++) {
const result = await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: `User ${i}`, postTitle: "Post", postUrl: "/p/1" },
})
expect(result.skipped).toBeFalsy()
}
// 31st send should be rate-limited
const blocked = await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "One More", postTitle: "Post", postUrl: "/p/1" },
})
expect(blocked.skipped).toBe(true)
expect(blocked.skipReason).toBe("rate_limited")
})
})| Test level | What it proves | I/O boundary |
|---|---|---|
| Template rendering | Payload fields map to the right template slots | In-memory, no provider or database |
| Channel delivery | Correct channels fire (inbox, email, SMS) | Fake provider, no network |
| Validation rejection | Malformed payloads fail gracefully with a reason | In-memory validation |
| Digest rendering | Single and multi-event render() both work | In-memory buffer and renderer |
| Rate limit | Cap is enforced at the configured threshold | In-memory loop; duration depends on the configured limit |