Backend push notifications: why your server should talk to your phone
You launched. Your first real user signed up at 2am while you were asleep. You found out at 9am when you opened your analytics dashboard.
That’s the gap. Your backend knew immediately. You didn’t.
The moment that matters is usually not when you’re watching
Most backend events worth knowing about don’t happen on a schedule. The first signup. The payment that fails three times in a row. The cron job that silently stopped running. The deploy that took four minutes instead of forty seconds.
You can check dashboards for all of these. You can also write a novel and check for reviews.
The alternative is a backend that tells you when something happens.
What developers actually reach for
Slack webhooks are the default answer and they work — until they don’t. The channel fills up. You start ignoring it. A critical error scrolls past during standup. The signal-to-noise ratio degrades roughly in proportion to how many developers you add.
Email is asynchronous by design. It’s fine for weekly digests and receipts. It’s not fine for “payments are failing right now.”
PagerDuty is genuinely good at what it does. It’s also designed for on-call rotations, escalation policies, and teams with SLAs. If you’re a solo developer or a small team and you want to know when your first user signs up, you don’t need an incident management platform.
Push notifications hit differently. They’re immediate, personal, and they respect your phone’s Do Not Disturb settings — meaning you can decide what wakes you up and what doesn’t.
The model: structured logs with push
The pattern that works is treating backend notifications like structured logs: every event gets a level, and the level determines what happens.
Normal events go to your feed. Important events also push to your phone. Critical events push with urgency — interrupting, requiring acknowledgment.
Here’s what that looks like in practice:
import fyi from "trigger.fyi"
// First signup — you want to know immediately
app.post('/signup', async (req, res) => {
const user = await createUser(req.body)
res.json(user)
// After response is sent — never on the critical path
fyi("New user", { body: user.email, plan: user.plan })
})
// Payment received
fyi("€29 payment", { body: "Pro plan · annual", userId: customer.id })
// Deploy complete
fyi("Deployed", { body: `commit ${sha} in ${elapsed}s`, env: "prod" })
// Failure — wake you up
fyi.critical("Payments failing", { body: error.message })Three things to notice. First, the signup notification fires after res.json(user) — the user’s request never waits for the notification. Second, the metadata (plan, userId, env) travels with the notification and becomes filterable in the feed. Third, fyi.critical() maps to Web Push urgency high — the OS treats it differently than a normal notification.
On serverless runtimes (Vercel, Cloudflare Workers), un-awaited promises can be dropped when the response ends. The safe pattern is either await fyi(...) — it’s one edge roundtrip and can’t throw — or pass it to your platform’s waitUntil.
The phone is the dashboard you always have open
A terminal TUI gives you context. npx trigger.fyi opens a live feed of your events, filterable by level and metadata, paginated, newest-first. That’s useful when you’re already at your desk.
Your phone is useful when you’re not.
The combination is the point. The feed is the structured log. The push is the interrupt. You tune which events are worth the interrupt — not by routing rules in a third-party platform, but by which function you call: fyi(), fyi.log(), or fyi.critical().
The key is the app. One environment variable, one key, one service. No channels, no topics, no routing configuration. If you need a separate stream for a different service, you use a different key.
One honest limitation
On iOS, web push cannot break silent mode. fyi.critical() on iOS behaves like a normal notification — it arrives, but it won’t override Do Not Disturb the way Android will. That changes with native apps. For now, the docs say so plainly, and the behavior is consistent.
The pattern is simple; the gap it closes isn’t
Your backend has always known things you found out later. First user, first payment, first failure, first sign that something is quietly broken. The tooling to close that gap exists. The question is whether you wire it up.
Three lines of code is a low bar for knowing what your backend knows, when it knows it.