The Angular Frontend: Handling Eventual Consistency in the UI and Showing Live Order Status
When an order is accepted with a 202, the UI should say "waiting for confirmation", not "confirmed"; Notifications pushes status changes over SignalR, but the page reads the order from Ordering again on every push. The Angular 22 app is served from one origin behind the BFF; a push arriving before the POST response, falling back to polling while Notifications is down, once-only e-mails with Redis, and stock, payment and price failures were tried in a real browser.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6, Angular 22.2 (TypeScript 6.0), @microsoft/signalr 11.0, MailKit 5.0 and Mailpit 1.33.
Guess First
Ada clicks "Place order" for Emma in her cart. The BFF answers 202 Accepted, Angular moves to the order page, and the Notifications service pushes the order's status to the browser over SignalR. Which comes first: the response to POST /api/orders, or the "order received" push? Second question: if Notifications is down at the moment the order is confirmed, what does the screen show, and when does the customer see the confirmation? The answers come from runs in a real browser.
Angular Behind the BFF: One Origin
The interface of Bookstore, the online bookstore used as the shared example across this site's series, is an Angular 22 application in the web/bookstore-web folder. Angular 22.2 requires TypeScript 6.0; ng new created the project with standalone components, signals and no zone.js. The application has no server of its own. The build output goes into the BFF's wwwroot, and the BFF serves it from the same origin as the API and the SignalR hub:
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "../../src/Services/Bff/Bookstore.Bff/wwwroot",
"browser": ""
},
"browser": "src/main.ts",
One origin simplifies three things. No CORS configuration is needed. The bookstore.session cookie from post 10 is attached to every request on its own. The Angular code contains no API address; every URL is relative: /api/catalog/books, /hubs/orders.
Locally we don't want to build by hand after every change. We added Angular to the AppHost as a JavaScript resource; Aspire runs npm install first, then starts ng build --watch through npm run watch:
// Locally "npm run watch" rebuilds the application into the BFF's wwwroot on every change, and the BFF
// serves it from the same origin as the API. The production build goes into the BFF's image (post 14).
if (builder.ExecutionContext.IsRunMode)
{
builder.AddJavaScriptApp("web", "../../web/bookstore-web", "watch")
.WithParentRelationship(bff);
}
The dashboard shows the web-installer resource as Finished and web as Running. On the BFF side we replaced the MapStaticAssets from post 09 with UseStaticFiles. MapStaticAssets only knows the files that were in wwwroot when the BFF was built, while ng build --watch writes new files while the BFF is running. We set the cache headers ourselves too: index.html is never cached, and files whose names carry a hash in the production build (main-UPE27H4M.js) are cached for a year. The real responses: Cache-Control: no-cache for / and /orders/123, no header for the development build's unhashed main.js. Unknown paths go to Angular's router through MapFallbackToFile("index.html").
The BFF required an X-CSRF: 1 header on every non-GET request under /api (post 10). In Angular an interceptor adds it; a second interceptor catches an expired session:
// The BFF rejects state-changing /api calls without this header (post 10). A form or an image on
// another site cannot add a custom header; HttpClient can.
export const csrfHeader: HttpInterceptorFn = (request, next) =>
request.method === 'GET' || request.method === 'HEAD'
? next(request)
: next(request.clone({ setHeaders: { 'X-CSRF': '1' } }));
// A 401 from /api means the session (or the access token behind it) is gone: sign in again and come back here.
// /bff/user is excluded: there a 401 only means "not signed in yet".
export const sessionExpired: HttpInterceptorFn = (request, next) => {
const session = inject(Session);
return next(request).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && request.url.startsWith('/api/')) {
session.signIn(location.pathname + location.search);
}
return throwError(() => error);
})
);
};
signIn does a full page navigation (/bff/login?returnUrl=...): the OIDC flow runs between the BFF and Keycloak, not in Angular. All Angular knows about the user is the name, e-mail and roles that /bff/user returns.
Catalog and Cart: httpResource and Signals
Most data loading uses httpResource. As in the Angular series post on Angular 20's stable signal APIs and httpResource, the request is defined as a function of a signal; when the search changes, the previous request is cancelled and a new one starts:
protected readonly search = signal('');
// The request is a function of the signal: a new search cancels the previous request and starts a new one
protected readonly books = httpResource<PagedResponse<BookSummary>>(() => ({
url: '/api/catalog/books',
params: { search: this.search(), pageSize: 20 }
}));
The book page uses the aggregation endpoint from post 09 (/api/books/{isbn}) and knows that stock may come back null. That is not theory: on the first book page after the system started, the BFF logged Stock for 9780141439587 unavailable: DeadlineExceeded and the page showed "Stock information is not available right now." The first gRPC call, the BFF fetching its client credentials token from Keycloak and Inventory's first request did not fit into the 500 ms deadline. The page still loaded and the add-to-cart button still worked; on the second load stock showed as In stock. We designed the partial failure in the BFF in post 09; the UI needs its counterpart.
The cart is owned by the browser, not by a service. No service cares about the cart until the order is placed; the cart is a signal written to localStorage on every change:
// The cart lives only in the browser: no service owns it until the customer places the order
@Injectable({ providedIn: 'root' })
export class Cart {
readonly lines = signal<CartLine[]>(read());
readonly count = computed(() => this.lines().reduce((sum, line) => sum + line.quantity, 0));
readonly total = computed(() => this.lines().reduce((sum, line) => sum + line.unitPrice * line.quantity, 0));
constructor() {
// Every change is written to localStorage, so the cart survives a reload and the OIDC redirect
effect(() => localStorage.setItem(storageKey, JSON.stringify(this.lines())));
}
localStorage is not decoration here. When Ada, not yet signed in, clicks "Sign in to order", the browser goes to Keycloak and comes back; the Angular application loads from scratch and everything in memory is lost. In the run, the only key in localStorage after we came back was bookstore.cart, and Emma was still in the cart. There are no tokens in the browser; all three cookies are httpOnly=true.
Checkout: Don't Say "Confirmed" on a 202
Since post 07 Ordering answers an order with 202 Accepted: the order is stored, but stock and payment are decided later by the saga. An optimistic UI, showing success before the server answers, suits a like or an add-to-cart; if it turns out wrong, undoing it costs little. Not an order. If stock is missing or the card is declined, you have told the customer "your order is confirmed"; by then they may have closed the page and started waiting for the confirmation e-mail. So the answer to a 202 is "we received your order and are checking stock and payment":
this.http.post('/api/orders', { items }, { observe: 'response' }).subscribe({
next: response => {
// 202 Accepted: the order exists, its outcome is not known yet. Location says where to watch it.
const location = response.headers.get('Location') ?? '';
this.cart.clear();
void this.router.navigateByUrl(location.replace('/api/orders/', '/orders/'));
},
error: (error: HttpErrorResponse) => {
this.placing.set(false);
this.problem.set(this.explain(error));
}
});
explain gives every status code its own sentence: on 409 the prices have changed, the cart takes the new prices and the customer sees the new total and decides again; on 429 the customer is asked to wait a few seconds (the rate limit from post 09). On status 0, when the request got lost on the network, there is no automatic retry. The request may have reached Ordering and the order may exist; Ordering's POST still takes no Idempotency-Key (post 04). We tell the customer to check "My orders" first.
Notifications: From Saga Events to a SignalR Push
Since post 06 Notifications listened to OrderPlaced, OrderConfirmed and OrderCancelled and wrote log lines. Now it has a SignalR hub. The hub has no methods the client can call; only the server sends. To find the user who should get the message we use the CustomerId we added to the events in post 10:
// The hub has no methods the client can call: the server only pushes. Who the user is comes from the
// access token the BFF forwards, never from the client.
[Authorize]
public sealed class OrderStatusHub : Hub<IOrderStatusClient>;
// SignalR looks for ClaimTypes.NameIdentifier by default; with MapInboundClaims = false Keycloak's subject is "sub".
// The same value travels in the events as CustomerId (post 10), so Clients.User(customerId) finds the connections.
public sealed class SubjectUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection) => connection.User.FindFirst("sub")?.Value;
}
public static async Task Handle(
OrderCancelled message, IHubContext<OrderStatusHub, IOrderStatusClient> hub, IOrderEmails emails, CancellationToken ct)
{
await hub.Clients.User(message.CustomerId).OrderStatusChanged(new(message.OrderId, "Cancelled", message.Reason));
await emails.SendOnceAsync(message.OrderId, "cancelled", message.CustomerEmail,
"Your Bookstore order was cancelled",
$"Order {message.OrderId} was cancelled: {message.Reason}. If you were charged, the amount will be refunded.", ct);
}
The browser does not connect to Notifications directly. The BFF got one more YARP route; AuthorizationPolicy requires a session, and the request transform from post 10 adds the user's access token to both the negotiate request and the WebSocket upgrade request. Notifications validates the token by audience like the other services:
"order-status-hub": {
"ClusterId": "notifications",
"AuthorizationPolicy": "default",
"Match": { "Path": "/hubs/orders/{**rest}" }
}
On the Angular side there is a single OrderStatusHub service: it opens the connection once the user is signed in, uses withAutomaticReconnect() and writes every push into a signal. SignalR itself (hubs, users, reconnects) is covered in the fourth, fifth and seventh posts of the Real-time Applications series.
A Push Is a Hint; the Truth Is in Ordering
The order page does not print the status carried by the push. The push only says "this order changed"; the page reads the order again from Ordering:
// A push for this order, or a reconnect after which pushes may have been missed: read the order again
effect(() => {
const change = this.hub.lastChange();
this.hub.resyncs();
if (!change || change.orderId === this.id()) {
untracked(() => this.order.reload());
}
});
// No live connection (Notifications down, a proxy that drops WebSockets): fall back to polling
effect(onCleanup => {
if (this.hub.state() === 'connected' || this.final()) {
return;
}
const timer = setInterval(() => this.order.reload(), 5000);
onCleanup(() => clearInterval(timer));
});
Why so indirect? The timeline of a run with a real Chromium answers that (seconds from the start of the test):
2.47 POST /hubs/orders/negotiate?negotiateVersion=1 -> 200
2.47 websocket /hubs/orders
2.55 frame {"type":1,"target":"OrderStatusChanged","arguments":[{"orderId":"01a1dabc-434d-7193-ae84-ddff1562c2d6","status":"Pending","reason":null}]}
2.57 POST /api/orders -> 202
2.60 GET /api/orders/01a1dabc-434d-7193-ae84-ddff1562c2d6 -> 200
2.60 UI: We received your order and are confirming stock and payment…
4.03 frame {"type":1,"target":"OrderStatusChanged","arguments":[{"orderId":"01a1dabc-434d-7193-ae84-ddff1562c2d6","status":"Confirmed","reason":null}]}
4.04 GET /api/orders/01a1dabc-434d-7193-ae84-ddff1562c2d6 -> 200
4.05 UI: Confirmed. A confirmation e-mail is on its way.
The answer to the first Guess First question: the push. The "Pending" push arrived 20 ms before the response to the POST. Ordering drains its outbox to RabbitMQ right after the commit, Notifications handles the message, and the push makes it before YARP passes the 202 to the browser. When the system is warm and the confirmation comes in 0.13 seconds (post 07), the Confirmed push may also arrive before the order page has opened. A page that trusted the push would miss it and stay at "waiting". Treating the push as a hint and Ordering as the single source of truth removes the race: the page reads the order when it opens anyway, and again after every push. The same push arriving twice is harmless too; at most one extra GET goes out.
E-mail: A Fake SMTP Server and an Event That Arrives Twice
For confirmation and cancellation e-mails the AppHost has a Mailpit container: it accepts every e-mail over SMTP, delivers none of them, and shows them in a web UI and an API. Notifications sends with MailKit; MailKit 5.0, released in the week this post was written, takes a SendRequest in SendAsync instead of the message itself. The real question is another one: Notifications has no database and no inbox, and RabbitMQ delivers at least once. If the same OrderConfirmed arrives twice, the customer gets two e-mails. In post 01 we gave Notifications Redis; here we use it:
IDatabase db = redis.GetDatabase();
string key = $"notifications:email:{orderId}:{kind}";
// SET NX: only the first delivery gets the key. Kept for 7 days, much longer than any redelivery.
if (!await db.StringSetAsync(key, "sending", TimeSpan.FromDays(7), When.NotExists))
{
logger.LogInformation("E-mail {Kind} for order {OrderId} already sent, skipping", kind, orderId);
return;
}
If sending fails, the key is deleted and the exception goes to Wolverine; SMTP errors have a retry rule with pauses of 1, 5 and 30 seconds. To try it, we used RabbitMQ's management API to publish the OrderConfirmed message of Grace's order to the bookstore.ordering exchange once more, with the same content and a new message id:
[notifications] E-mail confirmed for order 01a1dabb-c522-7cb9-b9eb-8a08876f1532 sent to grace@example.com
[notifications] E-mail confirmed for order 01a1dabb-c522-7cb9-b9eb-8a08876f1532 already sent, skipping
Mailpit held a single e-mail for that order. Let's write down the price too: the key is set before sending, so if Notifications crashes while sending, the key stays and the e-mail never goes out. For a notification e-mail we preferred that risk over a repeated e-mail; a document that must not get lost, such as an invoice, would need a service with an outbox and an inbox.
The Failure Experience: Stock, Payment, Price and a Lost Connection
On a cancelled order the customer should see why. We added a CancellationReason field to OrderResponse; oasdiff reported it as two info entries, not a breaking change (response-required-property-added). We tried three failure paths in the same browser session: 4 copies of War and Peace with 3 in stock, an order of 5,169 TRY that the fake payment provider declines, and Middlemarch sitting in the cart at its old price (379 instead of 389):
1.12 [stock] UI: Cancelled: Out of stock: 9781400079988
1.17 [stock] cart after put back: War and Peace TRY2,756.00
1.35 POST /api/orders -> 202
2.67 [payment] UI: Cancelled: Payment failed: card_declined
2.80 POST /api/orders -> 409
2.81 [price] Some prices have changed. Check the new total and place the order again. | Total: TRY389.00
2.89 POST /api/orders -> 202
4.90 [price] UI: Confirmed. A confirmation e-mail is on its way.
On a cancelled order the "Put the books back in the cart" button puts the books back; it pays for clearing the cart on the 202. Mailpit received two cancellation e-mails, both stating the reason.
Finally we took Notifications down. To keep the order waiting we first stopped Payments and placed an order; at second 20 we stopped Notifications, at second 46 we started Payments, and at second 77 Notifications:
19.98 websocket closed
20.01 Live updates: reconnecting | We received your order and are confirming stock and payment… [polling]
25.00 GET /api/orders/{id} -> 200
...
54.99 GET /api/orders/{id} -> 200
55.11 Live updates: reconnecting | Confirmed. A confirmation e-mail is on its way.
77.70 POST /hubs/orders/negotiate -> 200
77.70 websocket opened
77.72 GET /api/orders/{id} -> 200
77.75 Live updates: connected | Confirmed. A confirmation e-mail is on its way.
The answer to the second Guess First question: the moment the connection dropped, the page switched to 5-second polling and showed the confirmation a few seconds after Payments came back, while Notifications was still down. OrderConfirmed waited in the notifications.order-events queue meanwhile; when Notifications started it handled the message, sent the push and sent the e-mail. The push did not reach the browser because the connection was not up yet; the GET the page sent after reconnecting made up for it. One note: locally, Aspire's proxy keeps a connection to a stopped project open, so the client's first reconnect attempt waited until Notifications came back. In production a stopped pod refuses the connection right away; by default the client retries after 0, 2, 10 and 30 seconds and gives up. The page stays on polling in that case too (onclose).
In Production
The access token expires after five minutes, but the 401 does not come at minute five. We tried the ★☆☆ exercise of post 10 in the browser: 310 seconds after Ada signed in, "My orders" still returned 200. The reason is JwtBearer's TokenValidationParameters.ClockSkew: by default it allows 5 minutes for clock differences between servers, so an expired token is accepted for 5 more minutes. We repeated the test with 610 seconds:
0.92 websocket opened
610.90 hub: Live updates: connected
611.07 GET /api/catalog/books -> 200
611.15 GET /api/orders -> 401
611.20 GET /bff/login -> 302
611.30 (keycloak) GET /realms/bookstore/protocol/openid-connect/auth -> 302
611.33 (keycloak) GET /realms/bookstore/login-actions/authenticate -> 200
611.36 POST /signin-oidc -> 302
611.38 GET /bff/user -> 200
611.40 GET /api/orders -> 200
The 401 hit the interceptor in Angular and the browser went to /bff/login. Keycloak's SSO session was still open, so no password was asked; within 250 ms we were back on the same page and the list loaded. The user only saw a page reload, but anything typed into a form would have been lost. The real fix is the token refresh we left out in post 10: the BFF should renew the access token with the refresh token before it expires. A second observation: the WebSocket stayed open for all 610 seconds. The hub validates the token only when the connection is made; if a user whose token expired should stop getting pushes, look at CloseOnAuthenticationExpiration or .NET 11's authentication refresh (the sixth and ninth posts of the Real-time Applications series).
If Notifications runs more than one copy, you need a backplane. Clients.User(...) only sees connections to that instance; if the instance that pushes is not the one the user is connected to, the message is lost. The Redis backplane and sticky sessions are in the scale-out post of the Real-time Applications series. For us the loss is not serious, because the push is only a hint and the page can fall back to polling, but the "live" experience breaks.
Every proxy on the WebSocket's path has an idle timeout. Some load balancers close an idle connection after a minute by default; SignalR's 15-second keep-alive prevents that in most cases. We set no ActivityTimeout on the Notifications cluster in YARP; its default is 100 seconds, and the keep-alive resets it as well.
Watch local resources. While writing this post, when Docker's VM was shared with other containers, SQL Server dropped orders with There is insufficient system memory in resource pool 'internal'. We gave the SQL Server container MSSQL_MEMORY_LIMIT_MB=1024 and Keycloak JAVA_OPTS_KC_HEAP=-Xms128m -Xmx512m; by default both see all of the VM's memory and grow accordingly. We come back to resource usage, with numbers, in post 15.
Common Pitfalls
Showing a 202 as success. Saying "your order is confirmed" and then "cancelled" five seconds later is a worse experience than showing no confirmation at all.
Treating the push as the only source. Some pushes arrive before the connection is up, some are sent while it is down, some arrive twice; always read the state again from its owner (Ordering).
Retrying the POST automatically on a network error. Without an Idempotency-Key, the second request means a second order.
Moving the token into the browser. SignalR's accessTokenFactory may look tempting; with a BFF the token never reaches the browser, the connection is made with the cookie and the BFF adds the token.
Not making side effects in a consumer idempotent. Every e-mail, SMS or webhook sent by a consumer without an inbox goes out again when the message comes again.
Keeping the cart only in memory. The OIDC redirect reloads the page; the cart has to live in browser storage or on the server.
A Small Experiment
★☆☆ Put the status text on the order page into an element with aria-live="polite" for screen readers, and check that the change from "waiting" to "confirmed" is read out.
★★☆ Add Idempotency-Key support to Ordering's POST endpoint and generate the key in Angular when the cart is created. What should a request return that is sent again with the same key after a network error?
★★★ Run Notifications with two copies (WithReplicas(2)) and measure how many pushes get lost. Then add a Redis backplane with Microsoft.AspNetCore.SignalR.StackExchangeRedis and repeat the measurement.
An order's path now starts in the browser, goes through the BFF, Ordering, RabbitMQ, Inventory, Payments and Notifications, and comes back to the browser. When something on that path slows down, for now we find the slow step by putting logs side by side. In the next post we follow one order from Angular to RabbitMQ as a single trace with OpenTelemetry: HTTP, gRPC, EF Core and Wolverine spans, the trace context carried in message headers, and business metrics.