Size matters when streaming HTML to Safari

By Kristofer Palmvik ·

Streaming HTML from the server to Safari requires the document to have a certain size, both in bytes and pixels, before Safari attempts to render the partial response. Otherwise you will just see a white page until the entire document has been transferred.

I recently tried to get Remix to stream the HTML response to the browser to make at least some of the page to appear faster.

Remix has a great guide about streaming and the support of React <Suspense> through its own <Await> component and defer utility.

Streaming HTML response

Streaming HTML from the server works like this on a high level:

1. Send a complete document to the browser, where the deferred components are replaced with some kind of placeholders.

2. Keep the connection open until the promises are settled (fulfilled or rejected).

3. Append one or more script tags to the document with JavaScript that replaces the placeholders with the actual content.

Streaming does not work the same in Safari

But even after creating a brand new test project and deploying it with the minimal needed configuration I could not get streaming to work in Safari on iOS or MacOS.

My example worked fine in Chrome however which was confusing. Surely Safari supports streaming, right?

Turns out Safari does indeed support streaming, but not exactly as you (or I) would expect.

Luckily I found an issue reported for Sveltekit which described exactly the same problem:

"..this would rely on the browser starting to render the document before the response is fully received, which is not something that should be taken for granted — Safari, for example, does not do this, and waits for the response to be fully received before rendering the page.

That means, if the promise takes 10 seconds to resolve, the page will not begin to load (and not show the loading state as intended) for the entire 10 seconds, before finally loading with the data fully rendered."

After some discussion and experimentation they concluded that the content has to have a certain size in bytes. But that is not enough, as adding a long HTML comment does not solve the problem.

The content also have to have a certain pixel size on the screen.

This resulted in WebKit, the web browser engine used by Safari, bug 252413 which is currently still in status NEW.

"WebKit handles streaming HTTP responses very differently from other browsers.

First we thought that it has a bigger minimum byte count to accept the initially flushed response but we have disproven that.

You can add arbitrary bytes to the head, Webkit will still not paint early. Instead there seems to be a heuristic in place that requires a certain number of "contentful pixels" to be present to perform an initial paint."

In many real world cases this heuristic will work just great. But especially in a demo you might not provide enough content, and then get confused when it does not work.

After adding some more content to my test example, streaming worked as expected in all browsers including Safari.

This affects streaming in other frameworks too

I found a similar discussion in a Remix issue, coming to the same conclusion that this is on Safari to fix. I suggested the Remix streaming documentation to be updated with at least some reference to Safari's somewhat unexpected behavior.

There have also been issues reported for Next.js and React Server Components.

The Next.js documentation on Streaming with Suspense already contains an aside:

"Good to know: Some browsers buffer a streaming response. You may not see the streamed response until the exceeds 1024 bytes. This typically only affects “hello world” applications, but not real applications."

And it all points back to the same WebKit (Safari) bug.