Earlier this year, we started looking at the performance of the Stake home page, one of our highest-traffic pages.
The goal sounded simple: Make the page faster.
Our initial discovery uncovered a mix of quick wins and deeper problems that needed more investigation or reusable solutions.
One of the first things we looked at was the site loader. This was the full-screen animation users saw while the home page was loading, which effectively covered everything happening underneath until the page was ready to be shown.
The site looked smooth because of it, but that same loader was also hiding the problems underneath.
Once we removed it locally, we could finally see what had been hiding underneath: hydration flashes, layout shifts and a page that stayed unresponsive for a few seconds.
That gave us a much clearer starting point for the rest of the performance work.
Defining and measuring the metrics we are trying to optimise
We already had a lot of performance data. We had Web Vitals metrics, as well as loading times data, and could break performance information down by region, device or any other dimensions. It looked like we had everything we needed. But we realised these metrics didn't completely represent the user journey we were trying to improve. For a user, loading time is the answer to a very simple question: When can I actually interact with the page?
Our existing performance metrics were still useful, but they measured broader page activity. For this work, we knew we could place a performance marker at the exact point in the journey we cared about. So we introduced our own metric: page_rendered_complete — the point where the page content has been rendered and is visible to the user.
At this stage, we weren't trying to detect when every request had finished. We wanted a reliable marker for when the page itself had actually been rendered.
The flow looks roughly like this:
Navigation start
↓
Page component renders (home / games/home / sports/home)
↓
<PageReadyTracker /> mounts → onMount() fires
↓
performance.mark('page_rendered_complete')
↓
addPageTiming() → DD_RUM.addTiming(capturedAt)
↓
Perf test: measureToMark(navStart → mark)
= nav_to_page_rendered_complete → compared to budgetTo make this measurement consistent, we created a small reusable component called PageReadyTracker. When it mounts, it creates the page_rendered_complete performance mark, which is sent to Datadog for Real User Monitoring (RUM) and also used in our performance tests to measure from navigation start. This gave us one shared metric across production and local testing, and made it easier to compare changes against the same point in the user journey. Because the tracker is reusable, other pages and squads can use the same approach without creating their own implementation.
Validating performance changes
Ensuring performance for a web app that is served to a global customer base is a tricky process. While RUM samples performance statistics from real users, the act of reading and interpreting that data across a wide audience can introduce a lot of noise into the data. Serving the same application will give very different results, as sessions could range from a customer on a high-end PC with a home fibre optic connection located near our data centre to a user loading the application with an old Android phone on a dodgy regional network.
This posed a challenge which required us to develop a testing strategy that combined automated local testing of performance benchmarks with long-term monitoring of trend lines in RUM over longer periods.
For local testing, we created a performance bench which uses Playwright with Chrome DevTools Protocol (CDP) sessions, giving us access to all the APIs around a Chrome Developer performance profile that you would typically use when debugging performance issues locally.
By running the tests with both CPU and network throttles, we can validate that our changes improve at least one of these critical performance axes.
We also used AI agents as part of this testing loop. We gave them the performance traces, used them to identify bottlenecks, experimented with potential optimisations, and then compared the before and after results using the same benchmark.
That made it much faster to validate lots of small performance changes that would otherwise have taken significantly more manual investigation and testing.
Local environments are still far from production, as we are missing crucial pieces of infrastructure such as CDNs. So while this performance bench is a critical piece of the puzzle for local development, it does not account for the whole performance optimisation journey.
Hydration: when the server and browser disagree
One of the biggest visible problems was hydration.
Our application is server rendered. On the initial page load, the server generates the HTML first and sends it to the browser so the user can see content before all of the JavaScript has finished loading.
Once the JavaScript is ready, the browser hydrates that HTML. In simple terms, hydration is the point where the static server-rendered HTML becomes the interactive application.
That works well when the server and browser agree on what the page should look like.
Problems start when they don't.
For example, the server might render:
<Header loggedIn={false} />Then during hydration, the browser discovers the user is actually logged in:
<Header loggedIn={true} />The page now has to update immediately after it was first rendered. We found similar issues around authentication, cookies, responsive layouts, user-specific content and casino game tiles.
The site loader had been hiding many of these transitions. Once we removed it, they became much easier to see. So we applied a simple principle: The server and browser should agree as much as possible during the initial render.
This reduced hydration flashes, made the first render more stable, and avoided unnecessary browser work where the client had to correct what the server had already rendered.
The fixes: lots of small things add up
Once we had reliable measurements and understood what was happening during the initial render, we started working through the problems one by one. None of these fixes were particularly groundbreaking on their own. Most were well-known frontend performance practices. The important part was finding where we weren't applying them consistently and fixing them across the experience.
Some of the main changes included:
None of these changes solved performance on their own. Performance is usually the result of lots of small decisions building up over time. An image loads too late. A component renders before it’s needed. The server and client make different assumptions. A third-party script runs earlier than it has to. Fix enough of those small things, and the overall experience starts to change.
Streaming: render what you know
Another area we experimented with was streaming.
Before this, some parts of the page could hold up the rest of the response while slower data was still being fetched. From the user's point of view, that meant waiting longer before seeing content that was already ready.
With streaming, we could send and render the parts of the page that were ready first, while slower sections continued loading independently.
Instead of making the whole page wait for the slowest piece of data, we could progressively show useful content as it became available.
This helped us improve the initial experience without changing how the slower sections eventually loaded. For example:
{#await continuePlaying}
<ContinuePlayingSkeleton />
{:then games}
<ContinuePlaying {games} />
{/await}The user doesn't care whether every API request on the page has completed. They care whether the part of the application they want to use is ready. Streaming gives us another way to separate those two things.
The results
After all of these changes, the most important question was:
Did any of this actually make a difference? It did.
Home page loading time improved from 5.01 seconds to 3.85 seconds.
Our custom page_rendered_complete metric improved from 3.21 seconds to 2.50 seconds.
And CLS dropped from 0.80 to 0.18.
The numbers gave us something more useful than "it feels faster."
These larger improvements came from lots of smaller changes across the loading path that, together, reduced the amount of work the browser had to do before the home page became usable.
The next problem: don't make engineers discover all of this again
At this point, we had fixed a lot of problems on one page.
But the next question was: how do we stop other engineers from rediscovering the same problems?
These weren't only home page issues. They were patterns, and patterns can become tooling. That's where the work started moving from individual performance fixes into frontend platform work.
We started turning what we learned into things like:
The goal is to make the right thing easier to do. If a 5 MB image is a problem, CI should catch it. If there's already a recommended loading pattern, engineers should be pointed to it. If a JavaScript chunk suddenly grows, we should be able to see it. Engineers shouldn't need to become performance specialists to avoid problems we've already learned how to solve.
Observability before enforcement
One thing we've been careful about is not adding too many gates too quickly. It's easy to write something like:
if (chunkSize > MAX_CHUNK_SIZE) {
throw new Error('Chunk is too large');
}A growing JavaScript chunk might be a regression, or it might be the result of a valid feature. Without enough context, a CI check can quickly become something developers learn to work around.
So our approach is: Observe first. Understand second. Enforce third.
For bundle sizes, that means first understanding which chunks are growing, what normal changes look like, who owns them, and where actual regressions are happening. Once we understand the baseline, we can define meaningful performance budgets and add enforcement where it actually helps. The goal isn't for CI to simply say "No." but to give engineers enough context to understand what changed and whether they need to investigate.
From one home page to shared performance ownership
We started with a pretty narrow goal: Make the home page faster.
The Frontend Platform team, Forge, took ownership of the investigation, worked through the problems across the loading path, and the results showed that the approach worked.
But it also exposed a broader problem.
In a large codebase with many engineers and teams contributing to the same experience, performance can easily become no one's problem. Each individual change might be reasonable, but over time the cost adds up across images, JavaScript, third parties, rendering and data loading.
Forge had the cross-cutting view and the capacity to investigate those problems, but having one team continuously fix performance issues across the product doesn't scale.
The next step is to make that responsibility easier to share.
That means Forge can provide the measurement, tooling, patterns, observability and guardrails, while product teams have better visibility into the performance of the experiences they own.
That's what building a performance culture means for us: not having one team responsible for making everything fast, but giving teams the tools and information they need to keep their experiences fast themselves.
What’s next?
The next phase is about moving from individual improvements to stronger performance ownership across engineering.
A few areas we're focusing on:
The bigger goal is to make performance an engineering-wide practice, not something we only look at once a page becomes slow.
That means making performance part of our day-to-day engineering work.