/

Beyond the Canvas

Rabbit Hole of React Error Handling

Code Boundaries Blog Post
Code Boundaries Blog Post
Code Boundaries Blog Post

In March 2025, we released Code Boundaries—a bullet-proof wrapper around custom code. What started as a simple React error boundary became a month-long dive into intricasies of error handling. Here’s a peek under the hood.

Framer is a tool for no-code sites. But Framer does support code! Every Framer site is a (heavily optimized) React app, and site creators can add custom React components and overrides in any place they want.

However, custom code has a cost. Do you know what happens in React apps if some component throws an error? Right – the whole app breaks, and you get a white page:

Historically, this is just how Framer sites worked. If a code component broke, so did the whole site.

This winter, we set on fixing this. To solve this, we needed to make custom code independent: if a custom code component breaks, it should hide, but the rest of the site should keep working.

Welcome to the rabbit hole.

Level 1: Error Boundaries

On the surface, hiding stuff if it breaks is exactly what React error boundaries were made for. Wrap each custom code component with an error boundary → catch an error → render null → done:

class ErrorBoundary extends React.Component {
  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  render() {
    return this.state.hasError ? null : this.props.children
  }
}

Except: React error boundaries only work on the client! They will defend against crashes when you’re browsing a site, but not when we’re rendering it on the server. And if we can’t render a page on the server, we can’t optimize it, and it will remain slow:

Level 2: Suspense

On the server, React error boundaries are completely ignored. Instead, when a server-side error happens, React finds the nearest <Suspense> boundary and renders its fallback.

What this means is to hide erroring components, we need not one error boundary, but two – one for the client, and one for the server:

class ClientErrorBoundary extends React.Component {
  // (as above)
}

function ServerErrorBoundary({ children }) {
  return <Suspense fallback={null}>{children}</Suspense>
}

function ErrorBoundary({ children }) {
  return <ServerErrorBoundary>
    <ClientErrorBoundary>
      {children}
    </ClientErrorBoundary>
  </ServerErrorBoundary>
}

Unfortunately, wrapping every single piece of code with <Suspense> brings some challenges. That’s because, apart from catching errors on the server, it does several other things.

Level 3: So many behaviors of Suspense

Apart from error handling, <Suspense> has a bunch of other behaviors! And we don’t want all of those!

<Suspense> is a pretty overloaded primitive. Here’s (roughly) everything it does, as of 2025:

<Suspense> on the server

<Suspense> on the client

1. Renders a fallback if something suspends (can be skipped with await stream.allReady)

2. Renders a fallback if something suspends (can be skipped with startTransition())

3. Renders a fallback if an error happens

4. Makes hydration selective and concurrent

We’re happy with behavior 3 (it’s our goal!) and 4 (it’s a nice perf improvement!). But what if some user code suspends when you render it?

export function MyCodeComponent() {
  const weather = use(weatherPromise)
  return <div>It’s {weather.temperature}° outside!</div>
}

On the server, we can wait for the code to un-suspend by awaiting on stream.allReady. This makes behavior 1 acceptable as well.

But on the client, suspending like that will cause <Suspense> in <ServerErrorBoundary> to render its fallback – null (behavior 2). (That’s if the code that rendered the component isn’t wrapped with startTransition, of course – but we can’t control that code if it comes from a user.) As a result, the component will flash for a brief moment while it’s fetching its data. This is far from optimal.

How can we solve this?

  • Perhaps don’t render <ServerErrorBoundary> on the client at all? Nope, that will cause a hydration mismatch. <ServerErrorBoundary> renders a <Suspense>; and even though <Suspense> doesn’t emit any actual DOM nodes, it outputs special Suspense comments (<!--$--> and so on). If React can’t match those during hydration, it will complain.


  • Perhaps delete Suspense comments (<!--$--> and so on) before hydration starts? Nope, that will still cause hydration mismatches when the user code crashes on the server (and renders null) but succeeds on the client (and renders the correct JSX). Without Suspense, these hydration mismatches will cause the whole root to remount.


  • Perhaps suspend in the Suspense fallback? Now, this is something that could work. It turns out that:

    • if a Suspense boundary renders a fallback,

    • but the fallback itself suspends,

    • then React will simply ignore this Suspense boundary!

    function SuspenseThatIsIgnored({ children }) {
      return <Suspense fallback={<Suspend />}>
        {children}
      </Suspense>
    }
    
    function Suspend() {
      use(someInfinitePromise)
    }

    React docs mention that when this happens, the parent Suspense boundary gets activated. But this works even if there’s no parent Suspense boundary at all: the app behaves as if the Suspense boundary simply didn’t exist! Which, in the case of a suspending component, means suspending without activating any boundary.

    This sounds (and is) dangerous – we’re relying on not-really-documented React behaviors. In the longer term, we’ll implement a better solution. But as a short-term one, it ends up working surprisingly well: in most scenarios, if the user code suspends, nothing flashes at all.

This allows us to keep all the behaviors we like (#3, #4) but disable the ones we don’t (#2). Yay! Problem solved.

Level 4: External Components

Let’s zoom out to the product level.

In Framer, you can not only write your code – but also reuse code written by others. And this code can be nested inside no-code UIs also made by others:

This added another level of complexity to the implementation. Normally, custom code boundaries would hide only the component that crashes. But in the situation above, if Input crashes, we should hide not just it – but the whole Form.

Why? Because to the site author, Form is completely opaque. It’s not their component! They can’t peek inside it! To them, Form is an atomic control:

So crashing should also happen atomically – either not at all, or the whole component at once.

Levels 5…N

There were other product complexities we had to address.

  • Framer supports not only code components but also code overrides (or Higher Order Components, in React slang) – so Code Boundaries had to support that too

  • Many code errors are pretty cryptic by default, especially when the code is minified. So we had to develop a way to make it easy to find which exact component broke:

But we‘re finally done, and we’re proud of what we’re releasing.

Framer is a tool for no-code sites. But Framer does support code! Every Framer site is a (heavily optimized) React app, and site creators can add custom React components and overrides in any place they want.

However, custom code has a cost. Do you know what happens in React apps if some component throws an error? Right – the whole app breaks, and you get a white page:

Historically, this is just how Framer sites worked. If a code component broke, so did the whole site.

This winter, we set on fixing this. To solve this, we needed to make custom code independent: if a custom code component breaks, it should hide, but the rest of the site should keep working.

Welcome to the rabbit hole.

Level 1: Error Boundaries

On the surface, hiding stuff if it breaks is exactly what React error boundaries were made for. Wrap each custom code component with an error boundary → catch an error → render null → done:

class ErrorBoundary extends React.Component {
  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  render() {
    return this.state.hasError ? null : this.props.children
  }
}

Except: React error boundaries only work on the client! They will defend against crashes when you’re browsing a site, but not when we’re rendering it on the server. And if we can’t render a page on the server, we can’t optimize it, and it will remain slow:

Level 2: Suspense

On the server, React error boundaries are completely ignored. Instead, when a server-side error happens, React finds the nearest <Suspense> boundary and renders its fallback.

What this means is to hide erroring components, we need not one error boundary, but two – one for the client, and one for the server:

class ClientErrorBoundary extends React.Component {
  // (as above)
}

function ServerErrorBoundary({ children }) {
  return <Suspense fallback={null}>{children}</Suspense>
}

function ErrorBoundary({ children }) {
  return <ServerErrorBoundary>
    <ClientErrorBoundary>
      {children}
    </ClientErrorBoundary>
  </ServerErrorBoundary>
}

Unfortunately, wrapping every single piece of code with <Suspense> brings some challenges. That’s because, apart from catching errors on the server, it does several other things.

Level 3: So many behaviors of Suspense

Apart from error handling, <Suspense> has a bunch of other behaviors! And we don’t want all of those!

<Suspense> is a pretty overloaded primitive. Here’s (roughly) everything it does, as of 2025:

<Suspense> on the server

<Suspense> on the client

1. Renders a fallback if something suspends (can be skipped with await stream.allReady)

2. Renders a fallback if something suspends (can be skipped with startTransition())

3. Renders a fallback if an error happens

4. Makes hydration selective and concurrent

We’re happy with behavior 3 (it’s our goal!) and 4 (it’s a nice perf improvement!). But what if some user code suspends when you render it?

export function MyCodeComponent() {
  const weather = use(weatherPromise)
  return <div>It’s {weather.temperature}° outside!</div>
}

On the server, we can wait for the code to un-suspend by awaiting on stream.allReady. This makes behavior 1 acceptable as well.

But on the client, suspending like that will cause <Suspense> in <ServerErrorBoundary> to render its fallback – null (behavior 2). (That’s if the code that rendered the component isn’t wrapped with startTransition, of course – but we can’t control that code if it comes from a user.) As a result, the component will flash for a brief moment while it’s fetching its data. This is far from optimal.

How can we solve this?

  • Perhaps don’t render <ServerErrorBoundary> on the client at all? Nope, that will cause a hydration mismatch. <ServerErrorBoundary> renders a <Suspense>; and even though <Suspense> doesn’t emit any actual DOM nodes, it outputs special Suspense comments (<!--$--> and so on). If React can’t match those during hydration, it will complain.


  • Perhaps delete Suspense comments (<!--$--> and so on) before hydration starts? Nope, that will still cause hydration mismatches when the user code crashes on the server (and renders null) but succeeds on the client (and renders the correct JSX). Without Suspense, these hydration mismatches will cause the whole root to remount.


  • Perhaps suspend in the Suspense fallback? Now, this is something that could work. It turns out that:

    • if a Suspense boundary renders a fallback,

    • but the fallback itself suspends,

    • then React will simply ignore this Suspense boundary!

    function SuspenseThatIsIgnored({ children }) {
      return <Suspense fallback={<Suspend />}>
        {children}
      </Suspense>
    }
    
    function Suspend() {
      use(someInfinitePromise)
    }

    React docs mention that when this happens, the parent Suspense boundary gets activated. But this works even if there’s no parent Suspense boundary at all: the app behaves as if the Suspense boundary simply didn’t exist! Which, in the case of a suspending component, means suspending without activating any boundary.

    This sounds (and is) dangerous – we’re relying on not-really-documented React behaviors. In the longer term, we’ll implement a better solution. But as a short-term one, it ends up working surprisingly well: in most scenarios, if the user code suspends, nothing flashes at all.

This allows us to keep all the behaviors we like (#3, #4) but disable the ones we don’t (#2). Yay! Problem solved.

Level 4: External Components

Let’s zoom out to the product level.

In Framer, you can not only write your code – but also reuse code written by others. And this code can be nested inside no-code UIs also made by others:

This added another level of complexity to the implementation. Normally, custom code boundaries would hide only the component that crashes. But in the situation above, if Input crashes, we should hide not just it – but the whole Form.

Why? Because to the site author, Form is completely opaque. It’s not their component! They can’t peek inside it! To them, Form is an atomic control:

So crashing should also happen atomically – either not at all, or the whole component at once.

Levels 5…N

There were other product complexities we had to address.

  • Framer supports not only code components but also code overrides (or Higher Order Components, in React slang) – so Code Boundaries had to support that too

  • Many code errors are pretty cryptic by default, especially when the code is minified. So we had to develop a way to make it easy to find which exact component broke:

But we‘re finally done, and we’re proud of what we’re releasing.

Framer is a tool for no-code sites. But Framer does support code! Every Framer site is a (heavily optimized) React app, and site creators can add custom React components and overrides in any place they want.

However, custom code has a cost. Do you know what happens in React apps if some component throws an error? Right – the whole app breaks, and you get a white page:

Historically, this is just how Framer sites worked. If a code component broke, so did the whole site.

This winter, we set on fixing this. To solve this, we needed to make custom code independent: if a custom code component breaks, it should hide, but the rest of the site should keep working.

Welcome to the rabbit hole.

Level 1: Error Boundaries

On the surface, hiding stuff if it breaks is exactly what React error boundaries were made for. Wrap each custom code component with an error boundary → catch an error → render null → done:

class ErrorBoundary extends React.Component {
  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  render() {
    return this.state.hasError ? null : this.props.children
  }
}

Except: React error boundaries only work on the client! They will defend against crashes when you’re browsing a site, but not when we’re rendering it on the server. And if we can’t render a page on the server, we can’t optimize it, and it will remain slow:

Level 2: Suspense

On the server, React error boundaries are completely ignored. Instead, when a server-side error happens, React finds the nearest <Suspense> boundary and renders its fallback.

What this means is to hide erroring components, we need not one error boundary, but two – one for the client, and one for the server:

class ClientErrorBoundary extends React.Component {
  // (as above)
}

function ServerErrorBoundary({ children }) {
  return <Suspense fallback={null}>{children}</Suspense>
}

function ErrorBoundary({ children }) {
  return <ServerErrorBoundary>
    <ClientErrorBoundary>
      {children}
    </ClientErrorBoundary>
  </ServerErrorBoundary>
}

Unfortunately, wrapping every single piece of code with <Suspense> brings some challenges. That’s because, apart from catching errors on the server, it does several other things.

Level 3: So many behaviors of Suspense

Apart from error handling, <Suspense> has a bunch of other behaviors! And we don’t want all of those!

<Suspense> is a pretty overloaded primitive. Here’s (roughly) everything it does, as of 2025:

<Suspense> on the server

<Suspense> on the client

1. Renders a fallback if something suspends (can be skipped with await stream.allReady)

2. Renders a fallback if something suspends (can be skipped with startTransition())

3. Renders a fallback if an error happens

4. Makes hydration selective and concurrent

We’re happy with behavior 3 (it’s our goal!) and 4 (it’s a nice perf improvement!). But what if some user code suspends when you render it?

export function MyCodeComponent() {
  const weather = use(weatherPromise)
  return <div>It’s {weather.temperature}° outside!</div>
}

On the server, we can wait for the code to un-suspend by awaiting on stream.allReady. This makes behavior 1 acceptable as well.

But on the client, suspending like that will cause <Suspense> in <ServerErrorBoundary> to render its fallback – null (behavior 2). (That’s if the code that rendered the component isn’t wrapped with startTransition, of course – but we can’t control that code if it comes from a user.) As a result, the component will flash for a brief moment while it’s fetching its data. This is far from optimal.

How can we solve this?

  • Perhaps don’t render <ServerErrorBoundary> on the client at all? Nope, that will cause a hydration mismatch. <ServerErrorBoundary> renders a <Suspense>; and even though <Suspense> doesn’t emit any actual DOM nodes, it outputs special Suspense comments (<!--$--> and so on). If React can’t match those during hydration, it will complain.


  • Perhaps delete Suspense comments (<!--$--> and so on) before hydration starts? Nope, that will still cause hydration mismatches when the user code crashes on the server (and renders null) but succeeds on the client (and renders the correct JSX). Without Suspense, these hydration mismatches will cause the whole root to remount.


  • Perhaps suspend in the Suspense fallback? Now, this is something that could work. It turns out that:

    • if a Suspense boundary renders a fallback,

    • but the fallback itself suspends,

    • then React will simply ignore this Suspense boundary!

    function SuspenseThatIsIgnored({ children }) {
      return <Suspense fallback={<Suspend />}>
        {children}
      </Suspense>
    }
    
    function Suspend() {
      use(someInfinitePromise)
    }

    React docs mention that when this happens, the parent Suspense boundary gets activated. But this works even if there’s no parent Suspense boundary at all: the app behaves as if the Suspense boundary simply didn’t exist! Which, in the case of a suspending component, means suspending without activating any boundary.

    This sounds (and is) dangerous – we’re relying on not-really-documented React behaviors. In the longer term, we’ll implement a better solution. But as a short-term one, it ends up working surprisingly well: in most scenarios, if the user code suspends, nothing flashes at all.

This allows us to keep all the behaviors we like (#3, #4) but disable the ones we don’t (#2). Yay! Problem solved.

Level 4: External Components

Let’s zoom out to the product level.

In Framer, you can not only write your code – but also reuse code written by others. And this code can be nested inside no-code UIs also made by others:

This added another level of complexity to the implementation. Normally, custom code boundaries would hide only the component that crashes. But in the situation above, if Input crashes, we should hide not just it – but the whole Form.

Why? Because to the site author, Form is completely opaque. It’s not their component! They can’t peek inside it! To them, Form is an atomic control:

So crashing should also happen atomically – either not at all, or the whole component at once.

Levels 5…N

There were other product complexities we had to address.

  • Framer supports not only code components but also code overrides (or Higher Order Components, in React slang) – so Code Boundaries had to support that too

  • Many code errors are pretty cryptic by default, especially when the code is minified. So we had to develop a way to make it easy to find which exact component broke:

But we‘re finally done, and we’re proud of what we’re releasing.

Step into the future of design

Step into the future of design

Step into the future of design

Join thousands using Framer to build high-performing websites fast.

Join thousands using Framer to build high-performing websites fast.

Join thousands using Framer to build high-performing websites fast.