#javascript#typescript#css

What Actually Moved Our Largest Contentful Paint

· 3 min read

Every codebase has one area everyone is a little afraid to touch. On our team, it was everything related to hydration.

Most of the complexity was accidental. Over the years we'd layered three different approaches to hydration on top of each other, and each one made sense at the time.

Some background

When we finally measured, the numbers were worse than anyone guessed. Around 20% of the time spent on a typical change went into working around hydration rather than building anything.

We had good intentions. We even had a document describing how hydration should work. The trouble was that the document and the code had quietly gone their separate ways.

What we changed

The key insight was to treat hydration as part of the interface rather than an implementation detail. Once we did that, most of the other decisions followed naturally.

The core of it looks like this:

export function debounce<T extends (...args: any[]) => void>(fn: T, wait = 200) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

This alone let us delete a surprising amount of code elsewhere in the project.

A few things that helped along the way:

  • Keep the old path around until the new one has survived a real incident.
  • Write the migration guide before writing the migration.
  • Make the default the safe choice, not just the documented one.

Trade-offs

We still haven't solved bundle size. That's a post for another day, and honestly, I'm not sure we'll get it right the first time either.

The biggest cost was social, not technical. Changing how people think about hydration takes much longer than changing the code.

Closing thoughts

Six months in, nobody on the team wants to go back. That's about the best review a change like this can get.

HR
Hazel Rossi
Frontend engineer in San Diego. Writing about frontend engineering.