loke.dev
Header image for The Hydration Gap

The Hydration Gap

Your server and client are arguing over the HTML, and the resulting mismatch is a silent performance killer for your React and Remix apps.

· 4 min read

The Hydration Gap

I spent three hours last Tuesday staring at a console warning that claimed my <div> expected a "Thursday" but found a "Wednesday." It felt like my browser was gaslighting me over the linear progression of time, all because of one tiny toLocaleDateString() call buried in a component.

This is the "Hydration Gap." It’s that awkward moment where the HTML your server sent over doesn't quite line up with what React generates on the client. It’s more than just an annoying yellow warning in your DevTools; it’s a silent performance killer that can turn your snappy Remix app into a sluggish mess.

What is Hydration, Anyway?

Think of Server-Side Rendering (SSR) like a frozen pizza. The server does the heavy lifting of preparing the dough and toppings (the HTML), then sends it to your house (the browser). Hydration is the process of putting that pizza in the oven. React walks over to the existing HTML, attaches event listeners, sets up the state, and makes it interactive.

When everything is perfect, React says, "Cool, this HTML matches exactly what I was going to build. I'll just plug in the logic and call it a day."

But if the HTML is different? React panics. It throws away the server-rendered work, nukes the DOM nodes, and rebuilds the entire tree from scratch. You lose the performance benefits of SSR, and your users see a jarring flicker.

The Usual Suspects

Most hydration errors boil down to one thing: Nondeterminism. If your component produces different output based on *where* it’s running, you’re asking for trouble.

1. The Time Traveler's Bug

Dates are the most common culprits. If your server is in UTC (as it should be) but your user is in New York, a simple date format will mismatch.

// This is a hydration landmine
export default function EventCard({ timestamp }) {
  return (
    <div>
      Event starts at: {new Date(timestamp).toLocaleTimeString()}
    </div>
  );
}

The server renders the time for the server's locale. The browser renders it for the user's locale. Boom. Hydration mismatch.

2. The Browser-Only API

Trying to access window or localStorage during the initial render is a classic mistake. The server has no idea what a "window" is.

function ThemeToggle() {
  // This will be 'dark' or 'light' on the client, 
  // but undefined/null on the server.
  const savedTheme = typeof window !== 'undefined' 
    ? localStorage.getItem('theme') 
    : 'light';

  return <div className={savedTheme}>Current Theme</div>;
}

How to Close the Gap

We can't always avoid browser-specific data, but we can handle it gracefully.

The Two-Pass Render (The "useEffect" Trick)

The most reliable way to handle client-side data is to wait until the component has actually mounted. useEffect only runs on the client, so it’s the perfect place to trigger a change that relies on the browser environment.

import { useState, useEffect } from 'react';

export default function SafeDate({ timestamp }) {
  const [formatted, setFormatted] = useState(null);

  useEffect(() => {
    // This only runs after hydration is complete
    setFormatted(new Date(timestamp).toLocaleTimeString());
  }, [timestamp]);

  // Render a placeholder or a "safe" default first
  return <span>{formatted ?? 'Loading time...'}</span>;
}

By rendering a consistent placeholder on both sides, React stays happy. Once the app is interactive, the useEffect kicks in and fills in the real data.

The "I Know What I'm Doing" Escape Hatch

Sometimes, the mismatch is trivial and you just want React to shut up about it (like a third-party script injecting a class name). You can use suppressHydrationWarning.

<div suppressHydrationWarning>
  {new Date().getFullYear()}
</div>

Note: This only goes one level deep. It doesn't fix the performance hit of a full re-render; it just hides the warning. Use it sparingly.

Why This Matters for Remix Developers

Remix leans heavily into the web standards and SSR. Because Remix encourages you to fetch data in loaders (which run on the server), it's easy to accidentally pass data that looks different once it hits the client.

If you’re seeing hydration errors in Remix:
1. Check your Loaders: Are you transforming dates into strings in a way that is locale-dependent?
2. Environment Variables: Are you using a process.env variable on the server that is missing on the client?
3. Browser Extensions: Sometimes, password managers or "Dark Mode" extensions inject HTML into your page. This can trigger hydration warnings that aren't actually your fault.

The Bottom Line

Hydration issues are usually a sign that your components are "lying" about their state. The server and the client need to be in total agreement for that first render. If you need browser-specific info, don't try to sneak it in early. Let the server render a "clean" version first, then let the client enhance it.

Your users (and your console) will thank you.