A maintenance request sitting unread for four hours is a liability in a school — here's exactly how we wired real-time notifications into OpsFlow using SignalR so the right person knows the moment something needs attention.
A busted HVAC unit in a gymnasium on a 95-degree August afternoon is not a 'we'll get to it' situation. In K-12 facilities management, the gap between when a problem is reported and when the right person knows about it is measured in real consequences — kids sitting in heat, classrooms going offline, custodial staff duplicating effort because nobody knew someone else already submitted a ticket. When we were building OpsFlow, we knew the notification layer wasn't a nice-to-have. It was the whole point.
Before we shipped real-time notifications, the workflow looked like most facilities management workflows: someone submits a request, an email goes out, and then it enters a gray zone where you have no idea if anyone actually saw it, responded to it, or is working on it. Email is fine for asynchronous stuff. It is not fine when a pipe is leaking near an electrical panel. We needed something closer to a walkie-talkie than a mailbox.
This post is a technical walkthrough of how we integrated SignalR into OpsFlow — what the architecture looks like, why we made the choices we made, and what broke along the way. If you're building a vertical SaaS in any field where status changes matter in real time (healthcare, logistics, field services, education), the pattern we landed on is worth understanding.
Why SignalR and Not Something Else
We build OpsFlow on Blazor Server and .NET — we've written about that decision before. So the real question wasn't 'should we use WebSockets?' It was 'should we use raw WebSockets, Server-Sent Events, long polling, or SignalR?' SignalR is Microsoft's abstraction layer that handles all of those transport mechanisms and falls back gracefully depending on what the client supports. Given that we're already in the .NET ecosystem and our team knows C#, SignalR was the obvious fit. We didn't need to introduce a new technology stack to solve this problem.
One thing worth being direct about: SignalR is not magic. It's a well-built abstraction, but you still have to think carefully about connection management, group membership, message routing, and what happens when a connection drops. We've seen teams treat it like a plug-and-play solution and end up with ghost connections, missed messages, and memory leaks. The abstraction saves you a lot of low-level pain, but the architectural thinking is still on you.
We also evaluated Firebase Realtime Database and Pusher briefly, since a couple of team members had used them before. Both are solid, but they would have meant adding an external dependency and a separate billing relationship for a feature we could build natively. In a vertical SaaS that needs to run predictably inside school district IT environments — environments that often have strict firewall rules and vendor approval processes — keeping the stack consolidated is a real operational advantage.
The Notification Requirements We Were Actually Solving For
Before writing a single line of SignalR code, we spent time with our district contacts documenting exactly what needed to trigger a notification, who needed to receive it, and how urgently. This sounds obvious, but most teams skip it and end up building a generic notification system that notifies everyone about everything — which is how you get a custodian getting pinged every time an admin updates a comment field.
Here's what we landed on for the initial OpsFlow notification model:
- New work order submitted — notify the assigned facilities manager and any admin with district-level oversight
- Work order status change (open → in progress → completed) — notify the original requester and the assigned technician
- Work order reassigned — notify the newly assigned technician immediately, with the full context of the ticket
- High-priority flag applied — notify the facilities director, not just the assigned worker
- Work order comment added — notify all parties currently watching the ticket
- Scheduled maintenance window approaching — notify assigned staff 24 hours and 1 hour before
That last one uses a different mechanism (a background job scheduler, not SignalR), but the in-app notification delivery for it still routes through our notification hub. The point is: we had a defined list before we built anything. That list drove the architecture. If you're going into this with a vague requirement like 'users should get notified when things happen,' you'll build a system that's hard to maintain and even harder to explain to your clients.
The Hub: How We Structured the SignalR Layer
In SignalR, a Hub is the server-side class that handles connections and message routing. Think of it as the traffic controller. Clients connect to the hub, the hub places them into groups based on their role and context, and then the server can send messages to specific groups or specific connections without broadcasting everything to everyone.
For OpsFlow, we created a single NotificationHub. We debated whether to create separate hubs for different notification types, but one hub with well-structured group logic was cleaner and easier to reason about. When a user connects, we add them to groups based on their identity: their district group, their role group (technician, admin, director), and any work order-specific groups they're actively watching.
The group naming convention matters more than you'd think. We use a flat, predictable string format: district_{districtId}, role_{roleName}_{districtId}, and workorder_{workOrderId}. This makes it easy to target messages precisely and debug connection state in logs. When something breaks — and things will break — you want to be able to read a log line and immediately understand which group a message was targeting.
- OnConnectedAsync: add user to their district group and role group, log the connection with user context
- OnDisconnectedAsync: clean up group membership, log disconnection — this is where ghost connections hide if you're not careful
- JoinWorkOrderGroup: called when a user opens a specific work order detail page
- LeaveWorkOrderGroup: called when they navigate away — Blazor's component lifecycle makes this reliable
Sending Notifications From the Application Layer
The hub handles connections. The actual notification sending happens from our application services — the same services that handle work order state changes, assignments, and comments. We inject IHubContext<NotificationHub> into those services so they can push messages after a business operation completes. This keeps the notification logic close to the business logic without coupling them tightly.
Here's a concrete example: when a technician marks a work order as completed, the WorkOrderService updates the database record, then calls the notification service with the work order ID, the triggering event type, and relevant context. The notification service determines who should receive the notification based on the event type, constructs the notification payload, and calls the hub context to send it to the appropriate group. The requester who originally submitted the ticket sees a toast notification appear in their browser within a second or two — no page refresh, no polling, no email they'll read tomorrow.
We wrap all hub sends in try/catch blocks with logging. SignalR sends are fire-and-forget by default, and that's fine for notifications — we don't want a failed notification push to roll back a database transaction. But we do want to know when sends are failing consistently, because that usually means a connection management problem upstream.
Handling the Blazor Server Side
Blazor Server has its own persistent connection to the server via SignalR — that's actually how Blazor Server works under the hood. We're essentially running SignalR on top of SignalR, which sounds redundant but works cleanly in practice because the two connection layers serve different purposes. Blazor's SignalR connection handles UI state synchronization. Our NotificationHub connection handles application-level events.
In the Blazor component layer, we have a NotificationBell component that lives in the main layout. It connects to the NotificationHub on initialization, registers event handlers for incoming messages, and updates the UI reactively when notifications arrive. Because Blazor handles the component lifecycle predictably, we can reliably call InvokeAsync(StateHasChanged) from within hub event handlers to trigger a re-render when a new notification hits.
One thing we got wrong early: we initialized the hub connection inside OnInitializedAsync without accounting for the prerender phase. In Blazor Server, components can render twice — once on the server before the SignalR circuit is established, and once after. Trying to establish a hub connection during prerender throws an exception that's confusing to debug the first time you see it. The fix is to check if the component is in an interactive state before initializing the connection, which you can do by checking NavigationManager or using the new Blazor interactivity detection patterns.
- Initialize the hub connection in OnAfterRenderAsync on the first render only, not in OnInitializedAsync
- Always dispose the HubConnection in the component's DisposeAsync method to avoid memory leaks
- Use a reconnection policy — the default exponential backoff works well for school environments where network hiccups are common
- Handle the Reconnecting and Reconnected events explicitly so the UI can show a connection status indicator rather than silently failing
Persistence: What Happens to Notifications When You're Offline
Real-time delivery is great when you're connected. But a facilities director who closes their laptop at 3 PM and reopens it at 7 AM the next day shouldn't lose the notifications that fired overnight. SignalR pushes messages to live connections — if there's no connection, the message is gone. You have to handle persistence separately.
We built a Notifications table in the database that stores every notification event regardless of whether it was delivered in real time. Each record includes the recipient user ID, the notification type, the payload (work order ID, status, message text), a timestamp, and a read/unread flag. When the notification service fires a real-time push, it also writes to this table. When a user logs in after being offline, the NotificationBell component fetches their unread notifications from the database and populates the tray — real-time delivery is supplemental to the persistent store, not a replacement for it.
This pattern — durable store plus real-time delivery — is the right model for any notification system in a business application. The real-time layer is a UX enhancement. The database is the source of truth. Don't build a notification system where missing a real-time push means missing the information entirely.
What We Learned From Running This in Production
OpsFlow has been live in multiple school districts, and the SignalR notification layer has been one of the most stable parts of the system — but that stability came from working through some real issues in the early months.
The biggest production issue we hit was connection proliferation. When users left browser tabs open and walked away from their desks — which happens constantly in school environments — the hub accumulated idle connections. These weren't consuming much memory individually, but at scale across a district with dozens of concurrent users, we started seeing the connection count climb in ways that made us uncomfortable. We addressed this with a combination of client-side heartbeat monitoring and a server-side idle timeout that gracefully closes connections that haven't had meaningful activity in a defined window.
We also ran into an issue specific to districts that use Chromebooks heavily. Some older Chromebook builds had WebSocket limitations that caused SignalR to fall back to long polling. Long polling works, but it's noticeably less snappy than WebSockets, and in our case it increased server load in a measurable way during high-activity periods like the start of the school day. The fix was straightforward — make sure the SignalR client configuration explicitly prefers WebSockets and documents the browser requirements — but it's something you won't anticipate if you only test on modern desktop browsers.
- Monitor your hub connection count in production — it will tell you things your application logs won't
- Test on the actual devices your users use, not just Chrome on a MacBook
- Implement a client-side reconnection strategy with UI feedback — silent reconnections confuse users when notifications stop appearing
- Log hub events (connect, disconnect, group join/leave) with user context from day one — you'll need this for debugging
- Treat the persistent notification store as your primary system and real-time delivery as a delivery optimization
The UX Side: Notifications That Don't Annoy People
The engineering is only half the problem. The other half is building a notification experience that people actually find useful rather than something they immediately try to turn off. We've all used products where every minor event generates a pop-up. In a school district with dozens of maintenance requests moving through the system on a busy Monday, a naive notification setup would have facilities staff drowning in pings within an hour.
We built role-based notification filtering from the start. A custodian sees notifications relevant to their work orders. A facilities manager sees district-wide updates. A principal sees notifications for their building only. This isn't just a preference setting — it's enforced at the group membership level in SignalR. A custodian is never even placed in the groups that receive district-wide broadcasts. This means quieter clients and more relevant signal for everyone.
Toast notifications appear in the lower right of the screen and auto-dismiss after eight seconds. The notification bell shows an unread count badge. Clicking through to a notification marks it as read and navigates directly to the relevant work order. These aren't revolutionary UX decisions, but they're the right decisions — predictable, non-intrusive, and actionable. The best notification is one that gets someone to the information they need in one click and then gets out of the way.
Should You Build This or Buy It
If you're building a vertical SaaS and you're trying to decide whether to roll your own real-time notification layer or use a third-party service like Pusher, Ably, or Firebase, the honest answer is: it depends on your stack and your team's bandwidth. For us, building on SignalR inside our existing .NET infrastructure was the right call. We didn't add a vendor dependency, we kept full control of the data (which matters enormously in K-12 where student and staff data residency is a real concern), and the implementation fit cleanly into patterns our team already understood.
If you're on a Node or Rails stack without strong WebSocket tooling, or if you're building a product that needs global low-latency delivery at scale from day one, a managed service might be the smarter starting point. SignalR works excellently within a single region and scales well with Azure SignalR Service if you need to go multi-instance. For a district-level SaaS where the user base is bounded and latency requirements are measured in seconds rather than milliseconds, self-hosted SignalR is more than sufficient.
The larger lesson from building OpsFlow's notification system isn't about SignalR specifically — it's about the discipline of defining your requirements precisely before you build, choosing tools that fit your existing stack rather than chasing novelty, and treating the UX and the engineering as equally important parts of the same problem. Real-time notifications in a K-12 facilities product aren't a feature. They're the difference between a tool people check and a tool people depend on.

Paul Evans
Founder & Engineer, Phaseable
I've been building software for 20+ years. I founded Phaseable to build industry-defining vertical SaaS products and help founders with niche problems turn them into real businesses.
Keep Reading
How OpsFlow Went From a Conversation to a Live SaaS in 4 School Districts
It started with a relationship, a real pain point, and a problem no existing software solved well. Here's the full origin story.
Read MoreVertical SaaSHow to Find a Vertical SaaS Opportunity Worth Building
The best vertical SaaS products come from insiders who've lived the problem. Here's a framework for identifying the gaps worth solving.
Read More