Six months into a dashboard build, someone asks a question that should be trivial: is this balance current?
And nobody can answer it.
Not because anything is broken. Because the balance card, the transaction list and the FX panel were built by three people at three different times, and each one arrived at its own private theory about how old data is allowed to be before it stops being useful. One refetches on focus. One polls every thirty seconds because that felt about right. One caches until you hard-refresh. Nobody wrote any of it down, so nobody can tell you which is correct — and now the question "is this number current" has three answers depending on where you look.
I've built frontends for banks — NatWest, US Bank, TIAA-Nuveen — and this is the failure I've watched happen most often. It isn't a coding failure. It's a sequencing failure: the team designed the component tree first and treated data freshness as an implementation detail to be settled per widget, per ticket, by whoever picked it up.
It's backwards. Freshness is a contract, and it belongs upstream of the architecture — because what you decide there determines your transport, your caching, your error states, and the shape of the boxes themselves.
This is how I'd approach a banking dashboard, in the order the decisions actually have to be made.
The short version. Six decisions, in dependency order: who the user is → the freshness contract → architecture boundaries → where state lives → failure design → what you measure. Most teams start at boundaries and retrofit freshness per widget. That's the mistake this article is about.
Frontend system design is not backend system design in a hat
Worth establishing first, because most system design writing is backend writing, and the vocabulary we borrow doesn't fit.
Backend system design is mostly about scale under load. How do we store this, replicate it, keep it consistent, and not fall over at ten times the traffic?
Frontend system design is mostly about uncertainty at the edge. The data you're holding is already slightly out of date. The network will drop at the worst possible moment. The user is a real human who will click the button twice because nothing visibly happened. And the whole screen has to stay usable when a third of it is broken.
The backend designs the warehouse. The frontend designs the shop floor — where actual people, with actual confusion and actual patchy 4G, are trying to get something done.
Accessibility sitting in that right-hand column is not decoration. In this domain it's a legal requirement, and it has architectural consequences I'll come back to.
The six frontend architecture decisions, and why the order is not negotiable
Each decision narrows the next. Make them out of order and you spend the following year retrofitting.
The one that matters most is the placement of the freshness contract at number two — above the boundaries, above state, above everything structural. That's the inversion this whole piece argues for, and the rest of it is the argument.
Decision 1 — Who is actually using this dashboard?
"Banking dashboard" is not a specification. It could mean a retail customer checking whether their salary landed, or an operations analyst reconciling four hundred payments before a cut-off.
Those are not the same product. The first needs to be fast and legible on a phone at a bus stop. The second needs dense tables, keyboard-first navigation, and to stay responsive at ten thousand rows. Almost every number further down this article changes depending on which one you're building.
Nobody volunteers this. It arrives as an assumption inside a ticket, and if you don't surface it early you find out during UAT. For the rest of this piece I'm assuming retail with some business banking.
Then there's the second list — the one that never makes it into a ticket at all.
The left column is what the product manager asks for. The right column is what the compliance officer assumes is already true, because to them it simply is how the world works. Freshness guarantees. Role-based visibility. An audit trail of what a customer was shown at 14:32 when they later dispute what they saw. A session timeout policy you don't own and can't negotiate.
These reshape the architecture far more than anything on the left. And the cost of finding them late is not "some extra work" — it's discovering in week eleven that your entire caching strategy is incompatible with an audit requirement nobody mentioned.
Decision 2 — The freshness contract: how stale can each piece of data be?
Here's the decision most teams skip entirely, and the reason the opening story keeps happening.
The requirement usually arrives as one word: real-time. That word is doing an enormous amount of unexamined work, and the first thing to do is take it apart.
Five kinds of data on one screen, five genuinely different answers. The FX ticker needs a socket. The account balance needs a refetch on window focus and immediately after any transfer. Spending analytics is a batch job upstream — caching it hard isn't laziness, it's correct, and refetching it every thirty seconds is just wasted requests against a number that hasn't moved since last night.
Write this table down. Put it in the repo. It's the thing the architecture has to satisfy, and having it in one place is what stops three widgets developing three theories.
A stale balance is worse than no balance
This is the rule that makes financial UIs different from everything else, and it's worth being explicit about it because it inverts normal frontend instincts.
In a consumer app, showing slightly old data optimistically is good UX. Here, someone reads £4,000, doesn't see the direct debit that cleared four minutes ago, and sends money they don't have. The overdraft fee is real. The number was only wrong for a moment, and that was enough.
So the rule is: show the timestamp, or show a skeleton. Never show a number that is quietly wrong. Every balance on the screen carries an "as of 14:32" — not in a tooltip, not on hover, visible.
This is also why I'm conservative about optimistic updates here. Optimistically marking a "like" is fine; if it fails, nothing happened. Optimistically showing a completed transfer and then rolling it back is a genuinely distressing thirty seconds for someone who just moved their rent.
The accessibility consequence of a live ticker
Freshness decisions have accessibility consequences, which is why the two belong in the same conversation rather than in a checklist at the end.
The FX ticker updates every second or two. It must not be an aria-live region — a value that changes that often in a live region turns a screen reader into an unusable stream of announcements, and the user's only recourse is to leave. The ticker stays silent, and there's a separate control that reads current rates on demand.
That decision only exists because of what we decided about freshness. Make freshness a per-widget implementation detail and this gets discovered in an accessibility audit, months later, by someone with no context on why the ticker updates so fast.
Decision 3 — Where the architecture boundaries go
Only now does the structure get drawn, and it's driven by a single question: what can fail without taking anything else down with it?
1. The app shell. Routing, layout, session lifecycle, the top-level error boundary. Critically: the shell knows nothing about money. It doesn't fetch balances, and it doesn't know what a transaction is.
2. The widget layer. Each widget owns its own fetching, its own loading state, its own empty state, its own error boundary.
3. The data access layer. A query cache plus one central registry of query keys. This is where the freshness contract from decision 2 actually lives as code — one place that knows what "fresh" means for each kind of data, rather than forty components each with an opinion.
4. Transport. A typed REST client and, separately, one WebSocket connection. Not one socket per widget.
5. A BFF or API gateway. One contract for the frontend instead of six. It shapes payloads for this screen, strips fields the current role isn't permitted to see, and stops the browser fanning out to every core banking system directly.
The rule the diagram exists to enforce: if the balances service is down, the shell still renders, navigation still works, and exactly one card shows an error. Blast radius is a design output, not an accident.
Only after that is settled does the component tree mean anything:
<AppShell> ← routing, session, layout, top error boundary
└── <DashboardPage>
├── <BalancesWidget> ← own query, own error boundary
├── <TransactionsWidget>
│ ├── <Filters /> ← reads and writes URL state
│ └── <TxTable /> ← virtualised; 10k rows must scroll at 60fps
├── <SpendingWidget>
├── <FxRatesWidget> ← subscribes to the shared socket
└── <TransferPanel> ← the only widget that writes
Note the annotation on the last one. The only widget that writes. Reads and writes have completely different failure requirements, and separating them early is what makes decision 5 tractable.
The permission gate is not a security boundary
There's a permission gate in the cross-cutting column, and it's worth being precise about what it does.
It decides what renders. A declarative gate lets a component say "show this only if the user can approve payments," which keeps if (user.role === 'ADMIN') from spreading across two hundred files and — more usefully — makes permission rules unit-testable like any other business logic.
What it is not is authorisation. Hiding a button is a UX affordance. The API enforces authorisation, and it enforces it whether or not the UI behaved, because anyone can open devtools. Conflating the two is how a "permissions bug" turns into an incident report.
SSR vs CSR for a dashboard behind a login
The SSR question usually gets answered with SEO benefits, which is the wrong answer here: the entire dashboard sits behind a login. There is no SEO.
What's left is perceived performance — and that has to be weighed against something consumer apps never think about. Server-rendering the money means account balances pass through a rendering server. That's a new place where regulated data lives, new logs that might capture it, and new scope for a compliance review that runs on a quarterly cadence.
So: server-render the shell, which is fast and contains nothing sensitive. Fetch the financial data client-side. You give up a little first-paint completeness and you keep customer balances out of a system that would otherwise need its own audit.
Would I use micro-frontends here?
For a single dashboard owned by one team, no — and being willing to say so is worth more than adopting the interesting thing.
Micro-frontends solve an organisational problem. They exist so several teams can deploy independently without coordinating a release train. Adopt them without that problem and you've bought runtime version skew, cross-remote debugging and a much harder CI story in exchange for nothing.
The threshold is what matters: I'd reach for them when payments, cards and investments are separate teams blocking each other on releases. Below that, a monorepo with enforced module boundaries gets most of the benefit at a fraction of the operational cost. Enforced is doing real work in that sentence — convention alone doesn't hold, because someone always imports across a boundary at 6pm on a Friday. You enforce it with ESLint import rules or dependency-cruiser, failing in CI.
Decision 4 — Where each piece of React state should live
Boundaries settled, state placement follows almost mechanically — if you accept that "state management" is not one problem.

Server cache state — balances, transactions, spending totals, rates. You don't own this data; you're holding a copy that started going stale the instant it arrived. It belongs in a query cache — TanStack Query, RTK Query, or equivalent — and it should essentially never live in Redux or Zustand. This is where the freshness contract gets enforced.
URL state — selected account, date range, category filter, page, sort. In the URL, for a concrete operational reason: a support agent needs to be able to say "send me the link you're looking at" and see exactly the same screen. Put those filters in a store and you've made every support call harder forever.
Global client state — theme, sidebar collapsed, which modal is open, the toast queue, the idle-timeout countdown. One small store, and it should stay small. Growth here is the signal that something from another category is leaking in.
Local component state — an input's value, hover, whether a row is expanded. useState, right where it's used. Most state is this kind, and the correct action is to leave it alone.
Every unmaintainable React codebase I've opened failed the same test in the same place: server data living in a global store, slowly drifting out of sync with the server, with no single place that knows how old it is.
Decision 5 — What happens when a request fails
Every widget needs five states designed, not the usual two: loading, empty, error, offline, and partial — where the widget rendered but one field didn't resolve. Partial is the one people forget, and it's what produces "£—" in production.
But the decision that actually matters is on the one widget that writes.
A transfer has three outcomes, not two. Success, rejection, and unknown — the request timed out, the wifi died mid-flight, or a gateway returned 502 having possibly already forwarded the request.
From the browser, you cannot tell whether the money moved. That's not an edge case to handle later; it's a normal Tuesday on a mobile network.
So:
- The client generates an idempotency key once, before the first attempt.
- On an unknown outcome, either resend with the identical key — the server recognises it and returns the original result rather than moving money twice — or poll a status endpoint until it settles.
- The UI says "We're confirming this transfer. Don't resubmit — reference 7f3c…a91", and the submit button is disabled while it resolves.
What you never do is silently retry with a fresh key, or print "Something went wrong" and leave a person wondering whether their rent left the account twice.
And this is why decision 5 can't wait. The idempotency key and the status endpoint are both things the backend has to build. Discover this during implementation and you're negotiating an API change against another team's sprint, on the critical path, in week eleven. Discover it while designing and it's a line in a contract nobody has written yet.
Offline support for financial writes: mostly, don't
The instinct is to reach for a service worker and queue the writes. I'd argue against it.
Queueing a financial transaction offline and replaying it later is genuinely dangerous — the balance may have changed, the payee may have been blocked, the rate may have moved, and the user has long since stopped thinking about it. What you want is a read-only cached view with an unmissable banner: "Offline — showing data from 14:32." Reads degrade gracefully. Writes fail fast and honestly.
Deciding not to build something is a design decision, and this one is worth making explicitly rather than by omission.
Decision 6 — How you'd know the architecture was wrong
The last decision is the one that turns all the previous ones from opinions into something testable. For each significant call: which measurement would tell me this was a mistake?
On the freshness contract: cache hit rates per query key, and an alert on socket disconnect duration. If the FX socket has been silently dead for ten minutes, I want to know before a customer does — and if it disconnects constantly for users on corporate networks, decision 2 was wrong and polling was the right answer all along.
On boundaries: error rates per widget, not per page. A page-level error rate averages away exactly the signal you built the boundaries to produce.
On performance, budgets rather than vibes:
- LCP under 2.5s at p75, measured on the hardware users actually have — in a bank that often means locked-down corporate laptops behind an aggressive proxy, not an M-series MacBook.
- INP under 200ms for filtering and pagination.
- A JS budget for the initial load — shell plus first visible widget; everything below the fold lazy-loads.
- The transaction table virtualises. Ten thousand rows is a normal business account, and no amount of memoisation saves you from ten thousand DOM nodes.
Enforce those as budgets in CI. Performance work that isn't guarded regresses within a quarter, and it regresses invisibly.
On the WebSocket specifically — the decision I'd be least confident about. If most sessions last forty seconds, I may be paying connection overhead for a live-updating number nobody watches long enough to see update. Session-length data settles it, and I'd want that instrumented before committing rather than after.
What I'd expect to get wrong
The BFF is the piece I'd watch. It buys a clean contract and per-role field stripping, but it's another service to own and another deploy in the path — and if one team owns it while five teams need changes to it, it stops being a simplification and becomes a queue. That failure arrives slowly enough that you don't notice until it's structural.
The freshness table is the piece I'd expect to revise. Some of those numbers are guesses dressed up as decisions — "thirty to sixty seconds" for a balance is a reasonable starting point, not a finding. The value isn't that the numbers are right; it's that they're written down in one place, so when they turn out to be wrong there's a single thing to change rather than an archaeology project across forty components.
Which is really the argument of the whole piece. Most of these decisions can be revised later. The order can't. Get the sequence right and a wrong number is a one-line fix; get it backwards and the same wrong number is scattered across the codebase in six subtly different forms, and nobody can tell you which one was intentional.
Questions I get asked about this
Should a real-time dashboard use WebSockets or polling?
Neither, globally — decide per data type. A market-rate ticker that must be current within a second or two needs a socket. An account balance tolerating thirty seconds is better served by refetch-on-focus plus a refetch after any mutation, because that costs you no persistent connection and no reconnect logic. Picking one mechanism for the whole screen is how you end up over-engineering four widgets to serve one.
Where should state live in a large React app?
Split it into four kinds first: server cache state (a query cache), URL state (the address bar), global client state (one small store), and local component state (useState). The common failure is server data sitting in a global store, where it slowly drifts out of sync with the server and nothing owns its freshness.
Do you need SSR for a dashboard behind a login?
Not for SEO — there is none, the page is authenticated. The only remaining argument is perceived performance, and in a regulated product that has to be weighed against routing customer financial data through a rendering server, which widens your compliance surface. Server-render the shell, fetch the money client-side.
How should the frontend handle a payment request that times out?
As a third outcome, not as an error. A timeout means the request may have succeeded — you cannot tell from the browser. Generate an idempotency key before the first attempt, then either resend with that same key or poll a status endpoint until it settles. Never retry with a fresh key, and never show a generic error that leaves the user unsure whether their money moved.
When are micro-frontends actually worth it?
When separate teams need separate deploy cadences and are blocking each other on releases. Micro-frontends solve an organisational problem, not a technical one. For a single dashboard owned by one team, a monorepo with lint-enforced module boundaries gets most of the benefit without the runtime version skew.
What performance budget should an enterprise dashboard hold?
LCP under 2.5s and INP under 200ms at p75, measured on the hardware users actually have — which in a large organisation often means locked-down laptops behind a proxy, not a developer's machine. Enforce it in CI, because unguarded performance work regresses within a quarter.
I write about frontend architecture in regulated, high-stakes environments — the trade-offs, the things that broke, and what I'd do differently.
If you've built something like this: what did your freshness table actually look like, and how wrong were the first numbers? I'd like to know.






Top comments (0)