Real time, events and background work: SignalR, the outbox and hosted services
How a server speaks first (polling, server-sent events, WebSockets and SignalR), how modules hear each other's facts without losing one (in-process events and the transactional outbox), and how work runs by itself - hosted services, distributed leases, caching and signed webhooks - step by step, with pictures.
In short
On the web the browser normally speaks first and the server only answers. Real time turns that round: the server tells open screens that something changed, over WebSockets through SignalR. Inside the server, one module learns what another did through events, and the facts that must never be lost travel through a transactional outbox in PostgreSQL instead of a message broker. Work nobody clicked - schedules, retries, clean-up - runs in hosted services, and a lease makes sure only one server does each job at a time.
After this lesson you can Choose between polling, server-sent events and WebSockets; explain the transactional outbox and at-least-once delivery step by step; write an idempotent handler and a background worker that is safe on several servers; and sign and verify a webhook.
Words you need
- WebSocket One TCP connection that starts as HTTP and is then upgraded, so both sides can send messages at any time until one of them closes it.
- Server-sent events (SSE) An HTTP response that never ends: the server keeps writing small text events into it. One direction only - server to browser.
- Dual write Saving data and announcing it as two separate actions, so a crash between them leaves the system inconsistent. The outbox exists to remove it.
- Hub and group In SignalR a hub is the server endpoint browsers connect to; a group is a named set of connections, such as everyone looking at one document.
- Backplane A shared channel, usually Redis pub/sub, that copies a message sent on one server to every other server, so it reaches browsers connected anywhere.
- Event A message about a fact that already happened, named in the past tense: MemberLeft, OrderPaid. Whoever sends it does not know who listens.
- Message broker A separate server that stores and delivers messages between programs: RabbitMQ, Kafka, Azure Service Bus.
- Transactional outbox Writing the event as a row in the same transaction as the data it describes, then delivering it after the commit.
- At-least-once delivery A promise that a message arrives one or more times - never zero. The price is that it may arrive twice.
- Idempotent Safe to run twice: the second run changes nothing. Setting a status to Done is idempotent; adding 1 to a counter is not.
- Hosted service A long-running task started and stopped with the web server; in .NET usually a class derived from BackgroundService.
- Lease A claim that one server runs a job right now. Here it is a PostgreSQL advisory lock, which disappears by itself if its holder dies.
How a server speaks first: polling, SSE and WebSockets
HTTP is request and response: the browser asks, the server answers, and that exchange is over. To show a change someone else made, the server needs a way to speak first. There are four classic ways, from the simplest to the most capable.
- 1. Ask to upgrade The browser sends an ordinary HTTP GET with Upgrade: websocket and a random key.
- 2. Switch protocols The server answers 101 Switching Protocols with a hash of that key, proving it understood the request.
- 3. Frames both ways The same TCP connection now carries small frames - text or binary - in either direction, with no new request each time.
- 4. Keep it alive Ping and pong frames, or small keep-alive messages, stop proxies and load balancers from closing a quiet connection.
- 5. Close or drop Either side can send a close frame. Networks also drop connections silently, so the client must notice and reconnect.
Why {{brand}} streams AI answers with SSE An answer flows one way and starts with a question. So the question goes out as an ordinary POST, through the same checks as every request, and the answer is read as server-sent events with fetch and a stream reader - the browser's EventSource cannot POST. Because proxies sometimes buffer streams, a robust client keeps a plain request as a fallback.
SignalR: hubs, groups, reconnects and the backplane
SignalR is the ASP.NET Core library built on those transports. It negotiates the best one the browser and the network allow - WebSockets first, then server-sent events, then long polling - and gives both sides one simple model: the browser invokes methods on a hub, and the server sends named messages to one connection, one person, a group or everyone.
- 1. Negotiate The client asks which transports the server offers and receives a connection id.
- 2. Connect The connection opens with the person's sign-in cookie or token. The hub's OnConnectedAsync runs first and can refuse it.
- 3. Join a group The client calls a hub method such as Join(documentId). The hub checks the person may read that record, then adds the connection to the record's group.
- 4. Hear messages After a save anywhere on the server, code sends Changed(documentId) to that group, and every open screen in it hears it.
- 5. Drop and reconnect Wi-Fi blinks. With automatic reconnect the client tries again after 0, 2, 10 and 30 seconds by default, then gives up.
- 6. Catch up A reconnect is a new connection: its groups are gone and messages sent meanwhile were missed. So the client joins again and refetches.
A connection lives in the memory of one server. Put two servers behind a load balancer and Aynur may be connected to server A while Rustam saves on server B: B's message to the group never reaches A. A backplane fixes this - every server publishes hub messages to Redis pub/sub, and every server delivers what it hears to its own connections.
- 1. Save on server B Rustam saves a document on server B, and B's code asks the hub context to send Changed to the group doc:7.
- 2. Publish to the backplane Server B publishes that message on a Redis channel. Without this step only B's own connections would hear it.
- 3. Every server listens Server A and server B both receive the message from Redis.
- 4. Deliver locally Each server delivers it to the members of doc:7 that are connected to it.
- 5. Aynur sees it Aynur is connected to server A, so A delivers the signal and her screen refetches the document.
A long connection must keep proving who it is A WebSocket can stay open for hours, but a session can be signed out or a person removed in a minute. Teams usually combine three techniques: check access on every join, give the connection a short authentication lifetime so an automatic reconnect proves the session again, and put a version number in the group name so that when access to a record narrows, the old group goes silent.
Signal, then refetch: why the message carries no data
The simplest live message is a signal: this document changed, here is its id, nothing more. The screen then refetches it over normal HTTP. One small idea solves three hard problems at once - permissions, lost messages and ordering. A live board of cards uses exactly this technique.
- Save A person edits a document. The request saves it in the database - the source of truth.
- Signal After the save, the server sends a tiny Changed message with the document id to the document's group. If sending fails, the save still stands.
- Gather The browser waits a few dozen milliseconds for more signals, so ten quick changes become one request.
- Refetch It asks the normal HTTP endpoint for just those documents, and every permission check runs as for any request.
- Merge Only the changed documents are fetched and merged into the cache; the rest of the screen stays put and refreshes only what depends on those documents.
Pushing the data is still right when a message is small, final and addressed to people the server already chose - for example a chat message going to the members of one conversation. Where a screen shows a filtered view of many records, such as a board, send a signal.
Events: in-process dispatch, and the three ways to deliver
An event says that something already happened. The module that publishes it does not know who listens; each listener registers a handler. Inside one process this needs no infrastructure at all: a dispatcher asks the dependency-injection container for every handler of the event's type and calls them in turn.
The id of the stored event travels with every delivery, so a handler can tell a repeat from a new event. Later sections use it for idempotency.
Never publish a fact before its commit If handlers hear MemberLeft and the save then rolls back, other modules have already revoked access for someone who never left. A fact must be heard only once the change is durable - which is exactly what the outbox below guarantees. The next section compares the ways to deliver such facts; the transactional outbox, one of them, is taken apart step by step afterwards.
The options in the market
- In-process events only Handlers called in memory, straight after the save.; Nothing to run or learn; Fast and easy to debug; A crash after the commit loses the event; Publishing before the commit announces changes that may roll back
- In-process events + transactional outbox The event is a row in the same transaction, delivered after the commit and retried.; A committed fact is never lost; Uses the PostgreSQL already running; The same in a self-hosted installation; Throughput bounded by the database; Handlers must be idempotent; No history to replay for a new subscriber
- RabbitMQ A broker of exchanges and queues with acknowledgements; normally paired with an outbox.; Mature routing and dead-letter queues; Connects separate services in any language; Another server to run, secure and back up; Still needs an outbox to publish atomically with a database write
- Kafka A distributed, replicated log that consumers read at their own pace; normally paired with an outbox.; Very high throughput; Replay of history; many independent consumers; Heavy to operate; Order is kept only within a partition; Far more than one process needs
- Azure Service Bus A managed broker with queues, topics and sessions.; No servers to run; Dead-lettering, scheduled messages, ordered sessions; Ties the product to one cloud; A self-hosted installation cannot ship it
- Short polling Ask every few seconds whether anything changed.; Works everywhere; Servers stay stateless; Wasted requests; Changes arrive late
- Long polling Ask once; the server holds the request until there is news.; Works through almost any proxy; One request per message; held requests use server resources
- Raw WebSockets Open the socket yourself and design every message.; Full control, the smallest overhead; Reconnects, groups, fallbacks and scale-out built by hand
- SignalR Used for live screens; Transport fallback, reconnects, groups and users; Scale-out with a Redis backplane; First-class in ASP.NET Core; Its own protocol: clients need the SignalR library; Several servers need a backplane or a managed service
- Server-sent events Used for streaming AI answers; Plain HTTP through the normal request checks; Ideal for one-way streams; One direction only; EventSource in the browser is GET-only
- Managed real-time service Azure SignalR Service, Pusher, Ably; Scales without running a backplane; A monthly bill and another vendor; Not available to a self-hosted installation
What {{brand}} chose, and why
{{brand}} is a modular monolith: many modules, one deployable, one PostgreSQL. A message broker would add a server to run, secure, monitor and back up - for messages that never leave the process. The outbox gives the one thing a broker would add that matters here, durability, with the database that is already there.
- Atomic by construction: the event row commits or rolls back with the data, so there is never an event for a change that did not happen, nor a change without its event.
- Fast path first: the event is delivered right after the commit, in the same request, so a board turns red at once; the background worker is only the safety net.
- The same in every edition: a self-hosted installation needs nothing beyond PostgreSQL; Redis is optional.
- SignalR because the server is ASP.NET Core, and it brings reconnects, groups and a Redis backplane that is switched on by configuration.
- Hosted services with advisory locks instead of a job library: each job is a short loop, and PostgreSQL already has a lock that frees itself when a server dies.
The trade-off accepted Every committed fact costs the database one extra row, events can arrive out of order and more than once, and there is no history to replay for a new subscriber. Handlers are therefore written to read what is true now rather than trust the order of facts.
When a broker would be the right call Separate services written in different languages, very high event volumes, consumers that must replay history, or a team that already runs Kafka or Service Bus. Even then, keep the outbox: it becomes the reliable way to publish to the broker.
The transactional outbox, step by step
The outbox solves the dual write. Saving and publishing are two actions, and a crash can land between them. Save first and crash: the change exists but nobody hears about it. Publish first and the save fails: everyone hears about a change that never happened. The outbox turns the two actions into one transaction.
After a handler succeeds, the relay appends its name to handled_by: update outbox set handled_by = handled_by || 'RevokeTokensOnLeave' where id = $1;. A retry skips every handler already listed, so the ones that worked never hear the event twice.
- 1. One transaction The change and the event row are written together. A rollback takes the event away with the change.
- 2. Commit Now the fact is durable. A crash from this moment on cannot lose it.
- 3. First delivery Right after the commit, in the same request, the event goes to every handler. This is the fast path, and most events end here.
- 4. Mark per handler The row records which handlers took it (the handled_by column). A later attempt reaches only the ones that failed, so the others never hear it twice.
- 5. The relay A background worker wakes every few seconds and picks rows that are undelivered and due. A fresh row waits a short grace period, so the worker never races the fast path.
- 6. Retry with backoff A failing handler is retried with growing delays: seconds at first, then minutes, then hours.
- 7. Park After a fixed number of attempts the event is parked: logged as an error and listed for an administrator, who can send it through again once the handler is fixed.
- 8. Purge Delivered rows are deleted after a few days and parked ones after a few weeks, so the table stays small.
{{brand}} delivers almost every event on the fast path and runs the relay as one worker under a lease. The event row is written in the same transaction as the data it describes, so a fact is never separated from its change.
At-least-once delivery and idempotency
- Done, but not marked The handler saved its work, then the process stopped before the event was marked. The relay delivers it again.
- Slow, then retried A handler finished after the caller gave up waiting, and the retry runs it a second time.
- Two deliverers Without a lease, two servers' relays pick the same row in the same second.
- Sent again by hand An administrator sends a parked event through again after a fix.
- Set, do not add: write the final state (status = Done) rather than a change (count + 1).
- Read what is true now: check the current state before acting - a card already in the target column needs no move.
- Remember what you processed: store the event id under a unique index; a second insert fails and the handler stops.
- Use a natural key: one finding per failing streak, one bug card per outage - a unique key makes the second attempt a no-op.
- Give side effects an id: an email or webhook that carries the event id lets its receiver drop a repeat.
The processed row and the changes are saved together. If the processed row already exists, the unique index rejects the whole save, so the work is not applied twice. IsUniqueViolation is a small helper that checks the database error code (23505 in PostgreSQL).
Order is not guaranteed either A retried event can arrive after a newer one. Handlers that read the current state - who is a member now, which column the card is in now - end in the same place whatever the order.
Background work: hosted services and BackgroundService
In .NET an IHostedService is started and stopped together with the application. BackgroundService is its convenient base class: you write one method, ExecuteAsync - usually a loop - and it receives a stopping token that fires when the server shuts down. {{brand}} runs its background work as hosted services of the same application: schedules, reminders, outgoing email, webhook delivery, clean-up and the outbox relay.
- Start a little late Wait some seconds after start-up, so the app is ready before work begins.
- Take the lease Ask for the job's lease. If another server holds it, skip this round.
- Open a scope A hosted service is a singleton, but a database context is scoped: create a new scope for each pass and dispose of it.
- Do one small batch Take, for example, 50 due items, oldest first, and save each outcome before the next.
- Wait, with backoff After a good pass wait the usual interval; after each failure in a row wait twice as long, up to a ceiling, so an outage is not hammered.
- Stop cleanly When the stopping token fires, end the batch and return.
TryRunAsync returns false at once when another server holds the lease, so the loop just waits for the next round. WaitAfter doubles the wait with each failure in a row and stops at a ceiling.
Background work that acts for a person A schedule runs a test as the person who set it up. Run that work as that person - same permissions, same audit trail - and check at run time that they may still act. Never give background work an administrator's power just because nobody is watching.
- Hosted services + advisory locks Used for the scheduled and polling workers; No extra library or tables; Safe on several servers; Shares the app's code and configuration; Background work and web requests compete for the same machine unless you split them later; Schedules and dashboards are yours to build
- Hangfire Jobs stored in the database, with a dashboard; Fire-and-forget, delayed and recurring jobs; Built-in retries and a UI; Its own tables and serialized method calls; One more library to keep current
- Quartz.NET A full scheduler; Cron expressions, calendars, clustering; Heavy for short polling loops; Clustering needs its own schema
- A separate worker process The same code, deployed apart; Isolation: a busy job cannot slow the web; A second deployable to ship and scale
- Cloud timers Azure Functions, Kubernetes CronJobs; Pay per run; scales to zero; Ties to one platform; a self-hosted installation may not have it
Distributed leases with PostgreSQL advisory locks
Run two copies of an app and every worker runs twice: two reminder emails, two copies of a scheduled report. A lease lets the copies agree that one of them does each job. PostgreSQL has one ready-made: the advisory lock, a lock on a plain number that means whatever your application says it means.
- Name to number Hash the job's name, such as reminders, into a 64-bit key with a stable hash like SHA-256. .NET's string.GetHashCode differs per process, so two servers would compute different keys.
- Own connection Open a connection just for the lease: a session-level lock lives exactly as long as its database session.
- Try, do not wait Call pg_try_advisory_lock(key). It answers at once; false means another server has it, so skip this round.
- Work Run the pass while holding the lock.
- Release Call pg_advisory_unlock(key) and close the connection.
- Die safely If the server crashes mid-pass, its session ends and PostgreSQL frees the lock by itself. Next round, another server takes over.
Connection poolers can break session locks A pooler such as PgBouncer in transaction mode hands your server connection to another client between transactions, so a session-level advisory lock can be held for the wrong client or vanish early. Give lease connections session pooling.
Caching: IMemoryCache, Redis and cache-aside
A cache keeps a copy of something expensive to read or compute, for a short time. The common pattern is cache-aside: look in the cache first; on a miss read the source, store the result with a time to live (TTL) and return it. On a write, save to the database and then delete the cached copy, so the next read fetches the new value.
- Read: look in the cache Key product:42. A hit returns at once.
- Miss: read the source Query PostgreSQL, store the answer under the key with a TTL - for example 30 seconds - and return it.
- Write: save first An editor changes the product price, and the database row is updated.
- Then invalidate Delete the key. Deleting is safer than writing the new value, which can race with a slow reader still holding the old one.
- TTL as a safety net If an invalidation is ever lost, the copy still expires by itself within its TTL.
A cache is never the source of truth If Redis is slow or down, a request becomes slower, never failed - every miss falls through to the database. Design each cached value so that reading the source instead is always correct.
- Stampede: a popular key expires and a hundred requests rebuild it at once. Fix it with a lock per key, or refresh shortly before expiry.
- Stale copies across servers: with in-memory caches, server B keeps its copy after server A's change. Fix it with a shared cache or a short TTL.
- Key drift: one place caches under feature:x and another deletes Feature:x. Build every key in one place, never as inline text.
Outgoing webhooks: signing, retries and backoff
A webhook is an HTTP POST your system sends to someone else's address when something happens - a monitor went down, a card was completed. The receiver cannot ask questions first, so the message must prove where it came from, survive the receiver being down, and never let a customer's address be used to reach your own internal network.
- Queue, do not send A handler of a committed fact stores a delivery row. Nothing is sent inside the request, so a slow receiver never slows a save.
- Pick fairly A worker takes each endpoint's oldest due delivery, so one broken endpoint never holds up the rest.
- Sign Compute HMAC-SHA256 with the endpoint's secret over the timestamp, a dot and the raw body, and send it in a signature header with the timestamp.
- Send safely POST with a short timeout. Resolve the address first and refuse private, loopback and cloud-metadata ranges. Do not follow redirects blindly; treat one as a failure.
- Judge the answer Any 2xx means delivered. Anything else is retried with growing delays - minutes first, then hours.
- Give up gracefully After the last attempt the delivery fails. A 410 Gone, or a run of failed deliveries, switches the endpoint off until its owner turns it on again.
The scheme above is the one {{brand}} documents for webhook receivers.
If you are the one receiving webhooks Verify the signature first, answer 2xx quickly, put the real work on your own queue, and store the delivery id so that a repeat does nothing.
Try it yourself: an outbox and a lease in one evening
All you need is Docker and psql. You will see that a rolled-back change leaves no event, watch a duplicate appear after a simulated crash, and remove it with idempotency.
- Start PostgreSQL Run a postgres container and connect to it with psql.
- Create three tables orders, outbox and processed_events, as in the SQL below.
- Write in one transaction Insert an order and its OrderPlaced event. Repeat with rollback instead of commit and see that the outbox stays empty. Expected: select count(*) from outbox returns 1 after the commit, and still 1 (not 2) after the rolled-back attempt.
- Relay by hand Select pending rows for update skip locked, print them, then mark them processed. Expected: the committed event is printed once; after the update, the same select returns no rows.
- Crash in the middle Print an event and quit before marking it. Run the relay again: the event comes twice - that is at-least-once. Expected: the same id is printed twice.
- Make it idempotent Insert the event id into processed_events first; zero rows inserted means it was handled already. Expected: INSERT 0 1 the first time and INSERT 0 0 on the second run, which means already handled.
- Add a lease Wrap the relay in pg_try_advisory_lock in two windows and see that only one of them runs. Expected: true in one window and false in the other.
- Compute a signature Run the bash lines below. The last line prints the HMAC-SHA256 of the timestamp, a dot and the body; the hex value after the equals sign is the signature.
- Change one character Edit the body (for example ping to pong) and run it again. Expected: a completely different value.
- Compare Run it twice with the original body and the same timestamp. Expected: the value is identical every time - that is what lets a receiver check it.
- Verify in Node.js Build the header t=<timestamp>,v1=<signature> and feed it, the secret and the raw body to the verifier from the webhooks section. Expected: true for the original body, false after your change.
Common mistakes
- Publishing before the commit: handlers react to a change that then rolls back. Deliver after the commit, or write the event in the same transaction.
- Sending records over the socket: full data pushed to a group skips the permission checks of a normal request. Send a signal and let the screen refetch.
- Forgetting reconnects: after a reconnect the connection is new - join the groups again and refetch, or the screen silently goes stale.
- Handlers that are not idempotent: at-least-once delivery will run them twice one day. Set states, remember processed ids, use unique keys.
- Naive workers: a database context injected straight into a BackgroundService lives forever, and a worker without a lease runs twice on two servers. Open a scope per pass and take a lease.
- A cache that fails the request: when Redis is down, fall through to the database instead of returning an error.
Interview questions
- WebSockets, server-sent events or long polling - how do you choose? Two-way and frequent: WebSockets. A one-way stream over plain HTTP, like an AI answer or a log: server-sent events. Long polling only as a fallback where sockets are blocked - SignalR picks automatically.
- Is exactly-once delivery possible? Not across systems in general. You get at-least-once delivery and make the handler idempotent, so the effect happens once even if the message arrives twice.
- How do you scale SignalR to several servers? Each server knows only its own connections, so add a backplane - Redis pub/sub copies every group message to all servers - or use a managed service. Keep sticky sessions unless every client uses WebSockets only.
- Why send a signal instead of the data? The refetch goes through normal HTTP with every permission check, and a lost signal costs one stale moment because the database stays the truth. Push the data only when it is small, final and addressed to known recipients.
- What is the transactional outbox? Write the event as a row in the same transaction as the change, then deliver it after the commit and retry until it is handled. It removes the dual-write problem: no lost events and no events for rolled-back changes.
- Why not just use RabbitMQ or Kafka? In one deployable the messages never leave the process, so a broker adds a server to run and secure without adding much - and you would still need an outbox to publish atomically. A broker is right for separate services, very high volume or replay.
- What does at-least-once delivery demand from handlers? Idempotency: a second run must change nothing more. Set final states, read the current state, record processed event ids under a unique index, and tolerate events arriving out of order.
- How do you stop a scheduled job running on every server? Take a distributed lease: a PostgreSQL advisory lock on a stable hash of the job's name, tried without waiting. The holder runs the pass; if it dies, its session ends and the lock frees itself.
- What is cache-aside, and how do you invalidate it? Read the cache; on a miss load from the source and store it with a TTL. On a write, save first and then delete the key - the TTL is the safety net for a lost invalidation. A popular key expiring can make many requests rebuild it at once (a stampede); lock per key or refresh early.
- How do you make outgoing webhooks trustworthy? Sign the timestamp and raw body with HMAC-SHA256, and let receivers verify in constant time and refuse old timestamps. Queue deliveries, retry with exponential backoff, keep one delivery id across attempts and never follow redirects.
Related
- /help/technology/architecture
- /help/technology/data
- /help/technology/database-drivers-redis