Have you ever tried rendering a random number with Svelte before? Here's a rather quick implementation:
<script>
const n = Math.random()
</script>
<p>{n}</p>
Seems like nothing can go wrong, right? It even works if you plug it into the REPL. But as soon you plug it into SvelteKit, Svelte's website framework, oh dear what's happening?
The footage here is slowed down, but for some reason two random numbers get shown when the page loads, the second one rapidly replacing the first 😥. What's going on, and how do we fix this?
You won't get two flashing numbers. Instead, the console displays a nasty red error stating, "Warning: Text content did not match." This is definitely better since the site visitor won't see that, but it's still an issue, and it can lead to further problems if not addressed.A similar issue occurs when using NextJS.
Where do the numbers come from?
Essentially, the random number is being generated twice: once on the server, and once on the client computer. This is because some frontend frameworks, SvelteKit included, practice something called server-side rendering (SSR):
- The server runs the framework's Javascript code to generate nice juicy HTML, and the result is sent to the browser to render.
- The browser renders the result, and begins downloading the Javascript code.
- Once downloaded, the framework hydrates the existing HTML with the Javascript, making the page interactive.
Hydration
The hydration step is key to understanding the problem with randomness.
Imagine for a moment that you just purchased a new flashlight. Dang, it sure is nice that you don't have to build one yourself! Unfortunately, the flashlight does not come with batteries, so you have to install those before you can light up your day. To help you out, the shipment provides instructions on where the batteries should go:
What gives?! The instructions don't match the flashlight, so where am I supposed to put the batteries? How do I hydrate my flashlight?
</analogy>
When HTML is rendered server-side, it is not yet interactive; it only becomes interactive once Javascript has been applied. The process of applying interaction to server-rendered content is called hydration, and it relies on one key assumption:
The results of rendering on the server and the client should match.
In our flashlight analogy, when the instructions don't match the product, we get confused. Similarly, if client-side Svelte expects there to be a button on which to attach an event, and the server failed to render that button, then Svelte has to make a decision.
In the case of our random number, Svelte decides to replace whatever's there with what it thinks should be there. Since hydration takes time to do, we get a flash of two numbers.
Ensuring the same random numbers
If the problem is that the server and client are generating different numbers, then we need a way for them to generate the same numbers instead. In other words:
The server must send enough info for the client to generate the same numbers.
In particular, we can take advantage of the fact that random number generators are not actually random. In fact, they are pseudo-random, relying on well-defined algorithms to generate a pattern that is so chaotic that it's generally good enough.
Many such pseudo-random number generators take a seed as an input and use that seed to deterministically create an entire sequence of numbers. The key insight is that using the same seed always results in the same sequence of numbers.
Imagine if by saying a word out loud like "apple", you could cause your dice to always roll the numbers 4, 6, 3, 1, 6... in that order, every time. Seeding a random number generator is like that, and each seed produces a different sequence of numbers.
So step by step:
- The server generates a single random seed and uses that to generate HTML
- That seed is sent to the client along with the HTML
- The client initializes its own random number generator with the seed, ensuring the exact same sequence of random numbers are generated
- 💰💰💰
Step 2 differs in implementation from framework to framework, but let's see an example in SvelteKit (since that's what I was using when I ran into this issue).
A SvelteKit Example
In order to send the seed to the client consistently, we can rely on one of the properties of SvelteKit's special fetch
function:
It makes a copy of the response when you use it, and then sends it embedded in the initial page load for hydration
In other words, when Svelte makes a fetch
on the server, rather than forcing the client to do the same fetch
, it caches the result and sends it to the client, saving a lot of time.
Which means, if we have an endpoint that generates a random seed, we can guarantee the client sees the same seed!
Even though this example is for SvelteKit, a similar context-based strategy can be used with React's NextJS, except it's perhaps easier since an endpoint does not need to be set up.
1. Endpoint for a random seed
// random-seeds.js
export const get = async () => ({
body: {
// use your favorite algorithm for this
seed: generateRandomString(),
},
})
2. Get the seed in a layout
We want the seed to be available on all pages, so we can fetch it within the primary layout.
<!-- __layout.svelte -->
<script context="module">
export const load = async ({ fetch }) => {
// fetch's result will be cached for the client
const seed = await fetch('/random-seeds')
.then(res => res.json())
.then(json => json.seed)
.catch(() => '') // this shouldn't break the app
return {
props: {
seed,
},
}
}
</script>
You'll notice a catch
is used to ensure that any possible failure that might occur doesn't result in a crash. Proper error handling is always important to remember!
3. Create a seedable random number generator
Interestingly, Javascript's native random()
function cannot be seeded. Therefore, we have to find or roll out our own random number generator which can be seeded.
The seedrandom library by David Bau is very good for this, or if you want more control over what's bundled, feel free to choose from this list of pseudorandom number generators.
// lib/random.js
import seedrandom from 'seedrandom'
export const seeded = (seed) => seedrandom(seed)
export const usingMath = () => Math.random
4. Create a context for the generator to live in
We want the random number generator to be available everywhere in the app that it's needed without having to send it several layers deep via props. Svelte's context api is very useful here, since the __layout.svelte
is the root of every page.
// also lib/random.js
import { getContext, setContext } from 'svelte'
const key = Symbol()
export const generator = () => (getContext(key) ?? usingMath())()
export const setGenerator = (generator) => setContext(key, generator)
5. Initialize the generator in layout
We can now initialize the generator in the layout where we had previously fetched the seed to use.
<!-- __layout.svelte -->
<script>
import { setGenerator, seeded, usingMath } from '$lib/random'
export let seed = ''
setGenerator(seed.length > 0 ? seeded(seed) : usingMath())
</script>
<slot></slot>
6. Invoke the generator to get a consistent number!
And finally, we can use all this infrastructure to get a random number that is the same on both the server and client!
<!-- index.svelte -->
<script>
import { nextRandom } from '$lib/random'
const n = nextRandom()
</script>
<p>{n}</p>
Some Considerations
The approach I took above was perfect for my use case in which the random numbers were used solely for aesthetics. If, however, you need random numbers for something security-related, definitely double check whether this approach fits within your constraints!
Additionally, the solution presented here works iff the server and client generate content in the same order. You could imagine that if the client built the page backward, the same sequence of random numbers would be generated but they'd be applied to different pieces of the page. I'm not sure why this would happen, but if this is a significant factor for you, then it requires a more nuanced approach.
Here are a few other important factors to consider as brought up by comments below:
- Size: A great goal is to send as little to the client as possible, and a random number generator has non-zero size. Remember, the principle is for the server to "send enough info", and so a great alternative approach is just to send all the generated numbers.
- Performance: If we generate the numbers on the server and then again on the client, we're doing the same thing twice. For small counts of random numbers this is ok, but for large counts it can't be ignored!
Where it is important, be sure to consider the possible ups and downs of different approaches.
Github Repo
As usual, here's a repo to explore the raw solution. Hopefully it is helpful!
Key Takeaways
- If you are using a framework with server-side rendering, thoroughly examine and test places where it is possible for the server and client results to differ, such as with random numbers or login state.
- Where consistency is desired, the server must send enough info for the client to replicate the results exactly.
And a bonus tip:
- When you get stuck, ask for help! I was scratching my head for a while before asking the folks at Stack Overflow, and as a result I learned something new about how SvelteKit works.
Top comments (6)
Don't get me wrong seeded number generators are great especially for testing. But they also introduce additional dependencies increasing the bundle weight.
The core problem seems to be that during hydration the client didn't have access to the "random" values that were used on the server side.
Now lets imagine for a moment that not the seed but the random value is included in the "embedded fetch response".
Basically the closure allows us to stuff the server side values into the generator so that they are consumed first before the client starts generating its own independent values.
Indeed! That's a great function and works perfectly if you have only one number. In my case I needed to generate many numbers, so either I had to send all of them to the client (which I actually tried at first) or regenerate them. Both are valid solutions with tradeoffs.
Yes but how many do you actually need to get past hydration?
Normally one would use crypto.getRandomValues() / crypto.randomFillSync() to generate a pool of values.
Ideally you would just ship the values that were already consumed on the server side .
While a seed is a convenient single value there is the more general problem of:
"How do I ship enough initial state to the client so that hydration doesn't trash the already parsed and rendered page?"
One of the greatest challenges of SSR is to get the client to initially 'replay' the events that occurred server side to arrive at the exact state the server had when it rendered the page.
Seeded generators achieve 'replay-ability' by being predictable (which is kind of weird for "random values" - unless you're running simulations that have to be repeatable).
Perhaps the issue is how the component re-hydrates itself (i.e. component design) - the component should just be showing a value present in state (i.e. the random number generators are never run during hydration, they are just part of the "business logic" that runs once the app goes interactive).
This would mean that SSR apps need better component decoupling compared to CSR designs in order to avoid hydration issues.
It seems to me a simpler solution would be to write the server-side generated number into a global state, and have the client side component read from it if it is available. There is no need to compute the same operation twice.
Yep, performance is a great factor to consider! In my case I'm only needing to generate a hundred or so numbers, so the performance is negligible, but if someone's needing to generate many thousands or millions it becomes rather significant. I'll add your point as one of the considerations!
Thanks, learned a lot