Skip links
Abstract blue and cyan gradient cover for an article about pushing document status over SignalR instead of polling

Building real-time document status with SignalR

Open screens subscribe once, then named events are pushed to everyone who can see that container as the list, preview, indexer, tags, or extracts change, instead of every tab polling the API.

This article is the technical pair for A file in the document home isn’t always ready to work with. The business pair makes the case for treating a current filename as a filing update; this one is about the technical implementation.

It also builds on Building the upload pipeline that replaces a document cleanly, which already emits the list and canonical events this channel carries, and on The architecture of AI-native document management software.


The person who uploaded already knows

When someone puts a revised rate card in the document home, their own screen can already paint the new name from the HTTP response. Everyone else is still looking at last quarter until they hit refresh, or until a timer happens to ask. Real-time document status is the missing path, a push over Azure SignalR Service so every open screen that can already see that container hears which job finished, as it finished, without polling the API.

The upload pipeline can return 200 and still leave preview, search, tags, and extracts on their own clocks. A live channel that only says “something moved” is how you train people to ignore status. A channel that names the job is how two people sharing a library stay on the same snapshot.

Where this sits after the upload pipeline

Giving your documents a proper home settled the containers. Replacing a document is harder than uploading one and building the upload pipeline that replaces a document cleanly settled the bundle, including that upload doesn’t start the indexer. A file in the document home isn’t always ready to work with named the wait that leftover thread still has to cover. This piece is the plumbing, serverless SignalR in front of Azure Functions, with groups that follow the same permission boundary as the document home.

Full user authentication belongs in a later article. The contract for this channel is narrow. Negotiate as a signed-in user, join only the containers that user is allowed to see, and never let a dropped notifier fail an upload that already committed.

Where things go wrong

The common trap is a timer on every open tab. Each session asks the API whether anything changed. You pick between a slow UI and a noisy one, and you still miss the moment preview became available or the indexer finished. At document-heavy usage that pattern also costs more than a push, because idle tabs keep paying for questions.

The second trap is sending live messages only when the Function returns. Azure Functions’ SignalR output binding flushes on completion. Triggering an indexer then polls Azure AI Search about once a second until the run finishes or the wait budget runs out (default one minute). If “running” waits for that HTTP response, every other screen learns the indexer started at the same instant it already stopped.

The third trap is one generic event, or worse, a hub-wide blast. “Something changed” doesn’t tell a library view to refresh the list, a preview pane to retry the PDF, or an extraction board to reload a record. A message to every connected user, ignoring container membership, is also how people hear about libraries they aren’t allowed to open. The group is the access control for this channel.

The quieter cousin is coupling filing to the notifier. If SignalR is down, upload still has to succeed. A missing live update is a degraded UI, and a blocked write because the status widget failed is how you make Blob Storage depend on a WebSocket.

Real-time document status as a serverless SignalR path

This platform runs SignalR in serverless mode. There’s no always-on hub process inside the Function App. Azure SignalR Service holds the sockets. The app negotiates a connection, puts users in groups, and sends named events over the SignalR REST API when work actually finishes. One hub serves the document screens, and group names are container names.

The client never talks to SignalR until the Function has said yes. An authenticated POST /negotiate resolves the signed user, intersects their allowed containers with containers that actually exist (and includes the personal container), asks SignalR Service for a client token, and returns a hub URL plus that token. Then it adds that user to each allowed container group. If the SignalR endpoint setting is missing, negotiate returns 503 and the rest of the product still runs.

{
  "url": "https://<signalr-host>/client/?hub=<hub>",
  "accessToken": "<jwt>"
}

The client token is a JWT that SignalR Service issues itself through its token-generation endpoint, so it already follows SignalR’s client authentication rules. It’s issued for the hub URL, including the hub query, and carries the user id so group membership sticks to a person. The Function’s own REST calls carry a Microsoft Entra token from its managed identity, so nothing in the Function signs with an access key. Lifetime is one hour.

The browser then opens a WebSocket with that URL and token using the SignalR JavaScript client. It skips SignalR’s own negotiate, because the Function already did that job, and it reconnects on its own with a short backoff (0, then 2s, 5s, 10s, 30s).

const connection = new signalR.HubConnectionBuilder()
  .withUrl(url, {
    accessTokenFactory: () => accessToken,
    skipNegotiation: true,
    transport: signalR.HttpTransportType.WebSockets,
  })
  .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
  .build();
Negotiate flow returning a SignalR hub URL and JWT, then joining each allowed container group
Subscribe once as a signed-in user, join only the containers you can already see, then keep a WebSocket to SignalR Service. Image by author

Sends go to a group, which is how every current subscriber in that container hears the event without the app listing user ids. The payload is a target name plus arguments. Failures are logged and ignored by the caller. A five-second HTTP timeout is enough to try, and it isn’t enough to stall canonical conversion or an indexer poll.

requests.post(
    f"{endpoint}/api/hubs/{hub}/groups/{container}/:send",
    params={"api-version": "2024-12-01"},
    json={
        "target": "indexerStatusChanged",
        "arguments": [{"container": container, "running": True}],
    },
    headers={"Authorization": f"Bearer {token}"},
    timeout=5,
)

The decisions that keep the channel honest

Negotiate once and join the containers the user can already see

If the user is signed in, negotiate and join. If they aren’t, return 401 like every other document route. If SignalR isn’t configured, return 503 from negotiate and leave upload, search, and extract alone.

When the set of containers in the session changes, the client tears the socket down and negotiates again so group membership doesn’t go stale. The frontend also ignores events for containers it isn’t currently showing. That second check is extra filtering on the client. The group membership is the real boundary.

Name each event after the job that finished

If a mutation committed, send the event that names that job, then move on even if SignalR failed.

  • The filename landed or left. documentListChanged with uploaded, deleted, or canonical_ready. Other people refresh the list. The uploader’s own echo is suppressed so a bulk queue can finish and do one authoritative refresh instead of a spinner per file.
  • Preview can open, or it still can’t. canonicalStatusChanged carries canonicalStatus (pending, ready, failed). On a replace whose conversion died, the payload keeps canonicalStatus at ready and sets replacementStatus to failed, because yesterday’s canonical file is still the one preview should open. Lying here would advertise a broken generation.
  • The indexer is running or idle. indexerStatusChanged with running true or false, per container. When it goes idle, screens that didn’t start the run refresh chunks and reconcile which files still need indexing.
  • Tags changed. tagsChanged after a manual edit or a finished smart-tag merge. smartTagStarted / smartTagCompleted lock and unlock the row for everyone else looking at that file. If complete never arrives, the lock drops after sixty seconds and the list refreshes quietly.
  • An extract moved. extractorRecordChanged with extracted, saved, or submitted. The workflow board listens to this event so it can reload eligibility without sitting on the document-list firehose.

A file type that can’t be indexed still gets documentListChanged when it lands. It doesn’t get a canonical ready event that would imply search is about to catch up.

Named SignalR events for list, preview, indexer, tags, and extracts sent to a container group
Each job gets its own event on the container group, and indexer running/idle is sent while the Function is still polling. Image by author

Send while work is still running

If the indexer is about to poll, send running: true first, run the indexers in parallel, and send running: false in finally for each container. The same idle event fires if the outer handler blows up before a result is recorded. That’s why the send path is REST instead of an output binding.

Queued canonical conversion uses the same idea from a worker. The PUT can return pending and still emit documentListChanged plus canonicalStatusChanged. When the worker commits, it emits ready (and another list event with canonical_ready). If conversion runs out of retries, it emits the failure shape above. Those worker sends are wrapped so a notifier problem can’t poison a job whose bytes already committed.

Treat reconnect as a gap

A laptop that slept, a tab in the background, a dropped socket, will miss events. Automatic reconnect isn’t a complete stream. On reconnect the client refetches the document list silently and asks which indexers are running. Connect does the same snapshot, so the first paint doesn’t wait for an event that already happened.

Push is the happy path. A bounded silent poll still runs while a session upload is stuck in canonical pending, every five seconds, so a missed ready event can’t leave preview locked. That poll is recovery, and it isn’t how the product is supposed to learn that a file moved.

Reality check

Named events tell you that work started or finished. They don’t make the indexing pipeline cheap or instant. Layout, figures, and embeddings still take the time they take. The channel’s job is to stop people guessing during that time.

The Function authenticates to SignalR Service with its managed identity, and key authentication is turned off on the resource, so there’s no access key left to copy into a setting or a ticket. The identity needs a SignalR data-plane role in each environment, and a developer laptop without that role gets a 503 from negotiate while the rest of the product keeps working. How the same identity reaches the other services is covered in Passwords in app settings are a security risk and Zero secrets in code: managed identity for service-to-service auth.

Not every screen should subscribe to every event. The library view needs to know a name appeared and that preview became possible. Chat needs the search layer to have caught up, which is why indexer idle triggers a silent refresh instead of a chat-specific payload. The extraction board needs to know a record was posted. Dumping one firehose into every view is how you train people to ignore status.

A live channel costs money and operational attention. At document-heavy usage, the alternative is worse, because every open session polling every few seconds is how you pay more to stay less current. If you skip the channel, you’re betting that people will refresh at the right moment.

Org habit will still treat the list as the system of record for “done.” No notifier fixes a team that asks chat the instant the filename lands. The UI can show each of those jobs finishing, and people still have to read the signal.

Conclusion

The instinct after a file hits the document home is to celebrate the list, then to paper over the wait with refresh. That’s how teams ship a current filename on top of yesterday’s search, a preview that’s still converting, and an extract nobody has posted.

Real-time document status on this platform is serverless SignalR with container groups as the permission boundary, named events for list, canonical rendition, indexer, tags, and extracts, REST sends while a Function is still working, and a reconnect refetch for the events a sleeping laptop missed. Polling stays as recovery, and the product path is push.


Ideas, opinions, and tone are mine. AI helped with the language.

For more articles on AI-native document management visit the pialgorithms blog.


pialgorithms | document management software | ai engineering services

Portrait of Paris Perlegkas, founder of pialgorithms

Paris Perlegkas

Founder, pialgorithms