In this blog

The code this article is based on is available on GitHub. While the abstraction layer created should be fairly flexible to easily inject alternative approaches, there are still some assumptions in place that may need to be adapted for your particular use-case.

What is Remix?

Remix is a full-stack JavaScript framework based off React. It provides an opinionated way to write websites and web apps using server side rendering (SSR) with a hydration step that allows the app to transition into a single page app (SPA) after fully loading. This allows any Remix site to take advantage of rendering on the server (faster load times, better support for underpowered computers accessing the site) as well as the benefits of a client-side application (no full refresh during page navigation, live form validation) while only having to write the code for such functionality once.

Loader and Action Functions

One of the main patterns behind Remix's approach to write the same code for both the server and the client is the loader and action functions that can be associated with any route. Loaders in particular are run before the React app is rendered, allowing for retrieving of data ahead of time which greatly simplifies component design as React excels at the pattern of data flowing down from the top.

Enhancing the Defer Function

What does the Defer Function do?

In November of 2022, Remix introduced a new way to load data using the defer function. By returning the data as a Promise wrapped by the defer function, a webpage can be initially rendered with placeholders or fallbacks using the React Suspense Component. The placeholders are then replaced when the data has been resolved.

After the initial payload, the three instances of "Loading…" are replaced with "First Value", "Second Value", and "Third Value".

A big benefit to this approach is allowing the page to load as quickly as possible, delivering something rather than nothing for the user to look at.

Where this pattern really shines is allowing you to choose which data to load ahead of the initial render and which data to defer until later. For example, you could load the content of an article for the initial render but delay the comments section as it is unlikely the user needs access to the comments before the article itself.

The article is initially loaded with a section holding the place of the comments. After a bit of time, the comments themselves are inserted, replacing the placeholder.

However, there is a downside: deferred data does not work when JavaScript is disabled or fails to execute properly. Even though the vast majority of users have JavaScript enabled, some users will disable it to avoid the negative side effects JavaScript can have such as a sluggish user experience, data collection and fingerprinting, and extra data over of the network. Further still, the requirement of JavaScript goes against Remix's philosophy on progressive enhancement, the idea that a site should be fully functional without JavaScript but be made better by its presence.

The site stays in a permanent state of loading when JavaScript is disabled.

This leaves the developer with a choice: do you use deferred data and risk losing users who have JavaScript disabled? Or do you avoid using it and sacrifice performance that may also lose users due to slower load times?

Using Defer with JavaScript Disabled

One of the great things about Remix is that it avoids being a blackbox in a lot of cases for the developer. When a part of the standard API does not cover all use cases, Remix usually has a way to override the default behavior with custom functionality.

In this particular case, we want to keep writing loader functions and our React code the same as before. We instead want to alter the response behavior for all pages based on if JavaScript is enabled. We can get an idea of how to do this based off the entry.server.tsx provided in the default Remix project template when running npx create-remix@latest. First, we can see that there are two possible paths based on if the request is likely from a bot or from a user in a browser:

Default request handler tries to detect if the request is from a bot or a browser.

When we look at the two implementations, the main difference can be seen as to when the response is sent: it is resolved in onAllReady callback for bots, and onShellReady callback for browsers.

The request handler for bots uses the onAllReady callback, whereas the request handler for the browser uses onShellReady.

The difference between these two callbacks can be found in the react-dom documentation for renderToPipeableStream. To summarize:

  • onShellReady means that React has been able to do an initial render and can start streaming the webpage, with deferred content being sent later.
  • onAllReady means that all content is ready to be sent, including any deferred content.

Right off the bat, this would be an easy way to ensure those without JavaScript would still receive the whole application: if we detect JavaScript, we can use the onShellReady callback and if we do not, use onAllReady. We can modify the handleBrowserRequest in the following way:

  /* ... */
  return new Promise((resolve, reject) => {
    let didError = false;
    
    const jsDisabled = request.headers.get('cookie')?.includes('javascript-disabled');
    
    const handler = () => {
      const body = new PassThrough();

      responseHeaders.set("Content-Type", "text/html");

      resolve(
        new Response(body, {
          headers: responseHeaders,
          status: didError ? 500 : responseStatusCode,
        })
      );

      pipe(body);
    }

    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} />,
      {
        [jsDisabled ? 'onAllReady' : 'onShellReady']: handler,
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          didError = true;

          console.error(error);
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
  /* ... */

This sets up a simple cookie-based detection for which callback to use. We can then include a link or form on the page that allows the user to set the cookie, indicating they have JavaScript disabled. We can wrap this in a noscript tag to ensure only those with JavaScript disabled will be presented with it.

function JSEnabledFallback({ children }) {
  return (
    <div>
  	  <noscript>
  	    This part of the web page assumes JavaScript is enabled.
  	    <a href="https://www.wwt.com/javascript-disabled?redirect={url}">Click here to indicate you have JavaScript disabled.</a>
  	  </noscript>
  	  {children}
  	</div>
  );
}
The page now displays a link to click to indicate you have JavaScript disabled.

This way, we can use this fallback wrapper for any deferred content, allowing the user to know they are missing out on something in a particular area of the page so they can decide if that content is worth seeing. One downside of this is that we are still rendering and sending that content regardless of the user's preference, so leaving the section disabled does not give them any performance benefits. The other downside is that it requires a refresh on the part of the user, which since we are already deferring the content, could mean doubling an already long wait time for the page to finish.

Avoiding Two Page Loads

The downside of the above approach is that we cannot detect JavaScript before we start responding, so we are assuming JavaScript is enabled and providing a way to opt-out of the client-side application for the few who have it disabled. While this is perfectly valid and probably should be preferred for its simplicity and effectiveness, we can go further to reduce the friction by assuming the opposite: JavaScript is disabled until we get an explicit signal that it is not.

Detecting JavaScript

The easiest way to detect JavaScript is to start with the assumption that it is disabled and then have a small amount of JavaScript that reports back when it is successfully run. A minimal implementation of this looks something like this:

<script lang="javascript">
  fetch('/javascript-enabled'); // tell the backend JS is enabled
  document.currentScript.remove(); // remove this script tag
</script>

We will need to tweak the API endpoint call a little because of some of Remix's assumptions, but this is effectively what we will send. The reason for removing the script after it executes is because Remix attaches React to the entire document, meaning we either have to have this script be sent as part of the React app, or we need to remove it from the document so that React's hydration step never sees it and fails to reconcile the virtual DOM with the real DOM (though we could also suppress the error). Including the Script in the React app has the downside of sending a bit extra code and potentially making unnecessary API calls, so we will stick with detecting JavaScript separate from the React app.

Once we have identified whether JavaScript is enabled, we will want to store that information to avoid complexity when possible. The method of storing the state of JavaScript I choose is to use a cookie. The cookie is formatted to store both a unique identifier and whether JavaScript is enabled as true or false.

Even though setting a cookie ensures any future requests can know if the client has JavaScript enabled, it would be nice to ensure that even new users of the site can get the benefits of streaming on their first visit.

Option 1: Stream minimal HTML to trigger the API call

Before we tell React to start streaming, we can instead stream a small amount of HTML that includes our script to trigger the API call and set the cookie. On the server, we can watch for the API call and, upon it being triggered with the unique identifier, start streaming the React app in the original response, cutting off the small amount of duplicated HTML.

Nothing is shown when JavaScript is disabled until the all deferred content is resolved on the server.

The downside of this approach is that we need to make an assumption about the first few bytes we are sending (which could interfere with something like setting attributes dynamically on the html tag), though we could add a form of stale-while-revalidate check that ensures we send whatever the last response started with.

Option 2: Replace any placeholders as deferred data becomes available

We can start React streaming immediately, injecting our script to trigger the API call and set the cookie. This will work because if JavaScript is enabled, the script will set the cookie and remove itself and allow React to take control. If JavaScript is disabled, React's hydration step will not happen anyway and we effectively replace it with a form of server-side hydration.

Each piece of deferred data is sent to the browser as it become available.

The downside of this approach is the added complexity it introduces and the fact that it will have Remix-specific logic baked in that may at some point be broken by an implementation change.

Creating an Abstraction for Both Approaches

Both of these solutions still share a lot in common:

  • Direct access to the content of the response stream
  • Control when the response begins to stream
  • Mechanisms for detecting and recording whether JavaScript is enabled

Ideally, we avoid coupling our implementation with Remix or even React as much as possible but there is only so much we can do. We have already seen in entry.server.tsx how Remix ultimately interacts with React so a good place to start is by inserting our implementation in between the two so that we can control when and how they communicate.

JavaScript Detection Session

We already have an approach to detecting JavaScript: a cookie set by an API call. However, we might want to use a single cookie to represent multiple pieces of information, or combine it in some form of fingerprinting like using the IP address or request headers. To handle this, we will create an abstraction layer for session management.

export interface SessionManager {
  matches(req: Request): boolean; // test whether we should handle this request directly, (our API call)
  getSession(req: Request): DeferrableSession; // extract the session from the request
  setSession(session: DeferrableSession, res?: Response): Response; // attach the session to the response
  script(session: DeferrableSession): string; // generate a script tag for JavaScript detection
}

This will allow us to create a manager that will match the API request when necessary, extract the session information from the request's cookies (or create a new session for new users), and update the cookie when when necessary. We are also handing the responsibility of the script tag generation to the manager so that it can be customized as needed (for example, using query parameters to specify the session ID rather than relying on a cookie).

We also may need a way to communicate across multiple servers or instances of our app that the API endpoint has been called, so we will also create an abstraction layer for session persistence across multiple instances. This is also helpful for the default behavior of Remix, which is to isolate requests from one another.

export type PersistenceChangeCallback = (err: Error | null, session: DeferrableSession) => void;
export type Unsubscribe = () => void;

export interface SessionPersistence {
  persist(session: DeferrableSession): Promise<boolean>;
  destroy(session: DeferrableSession): Promise<boolean>;
  onChange(session: DeferrableSession, callback: PersistenceChangeCallback): Unsubscribe;
}

In the most basic case, we only care about persisting the session if the state is unknown and triggering the onChange callback for the initial request since we will then store the state of JavaScript being enabled in a cookie and no longer need to have the server store that information. There may be another approach that needs this flexibility though, so leaving the option open is still helpful.

Handling the Response Stream

Finally, we will need a way to represent our strategy for handling the response stream based on whether or not JavaScript is enabled.

import { PassThrough } from 'stream';
import type { DeferrableSession } from './Session';

type PipeFunc = (s: PassThrough) => PassThrough;
type ResolveFunc = (s: DeferrableStrategy) => void;

export abstract class DeferrableStrategy extends PassThrough {
  blockable = true;
  released = false;
  blocked = false;

  constructor(
    public session: DeferrableSession,
    public input: PipeFunc,
    public output: ResolveFunc,
  ) {
    super();
    this.blockable = !!this.session.deferrable;
  }

  abstract onReady(): void; // renderToPipeableStream.onShellReady
  abstract onComplete(): void; // renderToPipeableStream.onAllReady
  abstract onError(error: unknown): void; // renderToPipeableStream.onError
}

There are a couple of things happening here, so here's a breakdown of a strategy's properties:

  • Our stream can be blockable if we do not yet know if JavaScript is enabled.
  • Our stream can be considered released if we determine JavaScript is enabled and can "release" the rest of the stream to the user.
  • Our stream can be blocked if we are currently waiting on content from React or our API call.
  • We have a reference to the current session, the input pipe we are receiving from React, and the output pipe for responding to the user.
  • We have callbacks for React's renderToPipeableStream interface.

These abstractions all come together in our entry.server.tsx, hooking into React's renderToPipeableStream and taking over the responsibility of starting the streaming process.

function handleDeferrableRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise(async (resolve, reject) => {
    let didError = false;
    const session = sessionManager.getSession(request, sessionPersistor);

    const respond = () => resolve(
      sessionManager.setSession(session, new Response(strategy, {
        headers: responseHeaders,
        status: didError ? 500 : responseStatusCode,
      }))
    );

    const { pipe, abort } = renderToPipeableStream(<RemixServer context={remixContext} url={request.url} />, {
      onShellReady() {
        responseHeaders.set("Content-Type", "text/html");
        strategy.onReady();
      },
      onAllReady() {
        strategy.onComplete();
      },
      onShellError(err: unknown) {
        reject(err);
      },
      onError(error: unknown) {
        strategy.onError(error);
        console.error(error);
      },
    });

    const strategy = new StrategyImpl(session, pipe, respond);

    setTimeout(abort, ABORT_DELAY);
  });
}

There a few adjustments from Remix's default handleRequest:

  • Using our sessionManager to retrieve the session from the request.
  • Using our sessionManager to attach the session to the response.
  • Inserting our strategy as a middle-man between React and the response.
  • Hooking up our strategy callbacks to React's renderToPipeableStream.

We are also going to add a new Remix-specific handler that, while not necessary, makes our API call more convenient:

export const handleDataRequest: HandleDataRequestFunction = async (res, { request }) => {
  if (sessionManager.matches(request)) {
    const session = sessionManager.getSession(request, sessionPersistor);
    session.deferrable = true;
    await session.persist();
    return sessionManager.setSession(session);
  }

  return res;
};

By using handleDataRequest, we can adjust how our API call is detected without needing to create new route files or use as much Remix-specific logic. In this case, we have set it up to have a matches method to detect relevant requests. Then, we retrieve the session, mark the session as having JavaScript enabled, and send back a response that alerts this client of this change (in our case, by setting a cookie).

NOTE: With the persistence strategy we've created, the session will actually get removed from storage since we now are encoding the state in the cookie, meaning we no longer need to server to hold on to the information itself.

Approach 1: Block Until Complete

As mentioned before, the strategy here is to send a minimal amount of HTML and JavaScript to trigger our API call. If the call happens, we can unblock the stream and send the React App with the deferred data. Otherwise, we wait until React tells us that all the deferred data has been resolved.

export class BlockUntilComplete extends DeferrableStrategy {
  static prefix = '<!DOCTYPE html><html lang="en">';
  static marker = '<head>';
  private staged = false;
  private watcher?: () => void;
  /* ... */
}

Beyond the properties already available to us from the base DeferrableStrategy class, we also have the following properties:

  • A static prefix string to represent the minimal HTML we will send before our JavaScript to trigger the API call.
  • A static marker string to track where to splice in the React app content.
  • A staged flag to indicate whether we have streamed our prefix, marker and script.
  • A watcher or unsubscribe function for when we no longer need to watch the session state.

On Ready Callback

When the React app indicates that it is ready to stream content without deferred data (onShellReady), we first check if the session is deferrable, i.e. JavaScript is enabled. If this is the case, we can unblock the stream and allow all the React content through. If not, we need to determine if we do not yet know if we can defer the content.

  async onReady() {
    if (this.session.deferrable) {
      return this.unblock();
    }

    this.blocked = this.session.deferrable === false;

    if (!this.blocked) {
      this.output(this);
      this.write(`${BlockUntilComplete.prefix}${BlockUntilComplete.marker}${this.session.script}`);
      this.staged = true;
      this.blocked = true;

      await this.session.persist();
      this.watcher = this.session.onChange(() => this.unblock());
    }
  }

If the stream is not blocked, we let the output (our response) know we are starting to stream. We push out our prefix, marker, and the API call script. Then, we indicate that we have staged the API call, indicate the stream is blocked, and add a watcher to the session so that if the API call is made, we can unblock the stream again.

On Complete Callback

When the React app indicates that all of the deferred data has been resolved (onAllReady), we can first check if we have already released the the stream. If we have not released yet, we can unblock the stream, mark the session as not deferrable and save the session.

  onComplete() {
    if (this.released) return;
    this.unblock();
    this.session.deferrable = false;
    this.session.persist();
  }

Writing to the Stream

Because we have to send content that does not come from React and React can only write to the stream once (either onShellReady or onAllReady, not both), we need to control both what we send to in the response and when we tell React that we have started streaming.

Our unblock call is what controls this for the most part. We indicate that we are no longer blocked and check if we've already released: that is, React has been told to start rendering to the stream. The final thing we do is check if we have already staged our API call. If we have not yet, we can start the response and not worry about it because this means we know whether JavaScript is enabled or not and do not need to check anymore.

  private unblock() {
    this.blocked = false;
    if (!this.released) {
      this.input(this);
    }
    this.released = true;
    if (!this.staged) {
      this.output(this);
    }
  }

The second part we need to account for is when we have sent our script but have started to write more data. We can accomplish this by overriding the private _write method of our deferrable strategy stream.

  _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null | undefined) => void): void {
    if (this.staged) {
      this.staged = false;
      const str: string = chunk.toString();
      const stripPrefix = str.indexOf(BlockUntilComplete.marker) + BlockUntilComplete.marker.length;
      return super._write(str.substring(stripPrefix), encoding, callback);
    }

    return super._write(chunk, encoding, callback);
  }

If we have staged the script, we flip the staged flag so that we go back to normal stream behavior after this call. We then convert the chunk to a string and look for our marker (in this case, the <head> tag). We strip out all the content of the chunk up to and including our marker, and then send the rest of the chunk on.

Final Result of Block Until Complete

Nothing is shown when JavaScript is disabled until the all deferred content is resolved on the server.

We now get the desired result: When a first-time visitor to our site has JavaScript enabled, we can detect it and allow Remix's deferred behavior to proceed as expected. When JavaScript is disabled, we wait for React to resolve all the deferred data before sending the final result.

Approach 2: Replace Placeholders

This strategy is more complex and potentially more brittle than blocking, but the result is a more consistent experience regardless of if JavaScript is enabled. The idea is basically to emulate the behavior Remix would normally perform in browser on the server instead so that we can stream as much of the page to the browser as soon as possible.

interface Chunk {
  chunk: any;
  encoding: BufferEncoding;
  template?: string;
  callback: (error?: Error | null | undefined) => void;
}

export class ReplacePlaceholders extends DeferrableStrategy {
  private watcher?: () => void;
  buffer: Chunk[] = [];
  /* ... */
}

We need two new properties to make this work:

  • A watcher again, to unsubscribe from checking whether the session state changes.
  • A buffer of type Chunk to keep track of which content we are waiting to send.

On Ready Callback

Because we are going to preform the substitution ourselves, we can always kick off the React input and response output in our onReady callback. In addition to this we can add a check that if we currently have not determined whether JavaScript is enabled, create a session and listen for the API signal. If we get that signal, we mark our stream as not blockable and call flushBuffer to send on any chunks that had been blocked.

  async onReady() {
    this.blockable = !this.session.deferrable;

    if (this.session.deferrable === undefined) {
      await this.session.persist();
      this.session.onChange(() => {
        this.blockable = false;

        if (this.blocked) {
          this.flushBuffer();
        }
      });
    }

    this.input(this);
    this.output(this);
  }

On Complete Callback

Since we already start streaming in onReady, our onComplete is pretty simple: clear the session watcher, remove the session from the server, and call flushBuffer to ensure any chunks that have been blocked get sent to the browser.

  onComplete() {
    this.watcher?.();
    this.session.destroy();
    this.flushBuffer();
  }

Writing to the Stream

This approach takes a lot more code to implement due to the following needs:

  • Detect placeholders inside chunks. This requires splitting chunks into multiple pieces so that everything before the placeholder can still be sent.
  • Detect templates inside chunks.
  • Replace a placeholder with its respective template in the stream, which may cause the stream to continue or could be a placeholder further into the buffer.
  • React will not send more chunks until the proceeding chunk's callback has been triggered.

At the strategy level, we can pull some functionality into common methods to highlight the overall structure. The method handlePlaceholder simply puts the placeholder into the buffer separate from any of the other content in the chunk it was found in with a template property to track which template will ultimately replace it.

  handlePlaceholder(chunk: string, encoding: BufferEncoding, placeholder: Placeholder) {
    this.buffer.push({
      chunk: chunk.substring(placeholder.start, placeholder.end),
      encoding: encoding,
      template: placeholder.template,
      callback: noop,
    });
  }

The method substituteTemplate then uses this template property to find the corresponding placeholder entry in the buffer. If it is the first entry, we releaseUntilNextPlaceholder which empties the buffer until another placeholder is found. If none is found, we can consider the stream to no longer be blocked.

  substituteTemplate(template: Template) {
    const placeholder = this.buffer.find(chunk => chunk.template === template.template);

    if (!placeholder) {
      return console.warn(`Unable to find placeholder for template: ${template.template}`);
    }

    placeholder.chunk = template.html;

    if (placeholder === this.buffer[0]) {
      this.releaseUntilNextPlaceholder();
    }
  }

  releaseUntilNextPlaceholder() {
    const releaseUntil = this.buffer.findIndex((chunk, i) => i && chunk.template);
    const release = this.buffer.slice(0, releaseUntil);

    this.buffer = this.buffer.slice(releaseUntil);
    release.forEach(chunk => super._write(chunk.chunk, chunk.encoding, chunk.callback));

    if (!this.buffer.length) {
      this.blocked = false;
    }
  }

There is still a lot of orchestration necessary in the implementation when overriding the _write function to do template substitution and splitting up of chunks to isolate placeholders and the rest of the content. The code is too robust to include in this article, but is available here in on GitHub.

Placeholder and Template Detection

The method Remix uses in the browser to substitute templates for placeholders makes it pretty easy to detect and match on the server as well.

<!--$?-->
  <template id="B:1"></template>
  <div>My Placeholder</div>
<!--/$-->

<div hidden id="S:1">
  <div>Deferred Content</div>
</div>
<script>/* Remix script that does the substitution */</script>

For placeholders, we can detect the opening and closing comment markers and then extract the ID from the template tag.

export function matchPlaceholder(chunk: string, offset?: number): Placeholder | null {
  const placeholderStart = chunk.indexOf('<!--$?-->', offset);
  if (placeholderStart === -1) return null;

  const placeholderEnd = chunk.indexOf('<!--/$-->', placeholderStart + 9); // from the end of the first comment
  const before = chunk.indexOf('<template id="B:', placeholderStart + 9);
  const after = chunk.indexOf('"></template>', before);

  return {
    start: placeholderStart + 9,
    end: placeholderEnd,
    template: chunk.substring(before + 16, after), // grab the id in 'id="B:<id>"'
  };
}

Extracting templates is very similar:

export function matchTemplate(chunk: string, offset?: number): Template | null {
  const templateStart = chunk.indexOf('<div hidden id="S:', offset);
  if (templateStart === -1) return null;

  const templateEnd = chunk.indexOf('<script>', templateStart);
  const after = chunk.indexOf('">', templateStart);

  return {
    start: templateStart,
    end: templateEnd,
    template: chunk.substring(templateStart + tStart.length, after), // grab the id
    html: chunk.substring(after + 2, templateEnd - 6), // grab everything inside the `div` 
  }
}

Now when we find a placeholder or template, we can separate it from the rest of the chunk it sits in and relate it to its matching counterpart.

Final Result of Replace Placeholders

Each piece of deferred data is sent to the browser as it become available.

The page loads as much content as possible before pausing at the first placeholder. When the template becomes available, it is swapped in and the stream continues until the next placeholder. This continues until all the placeholders have been replaced.

Bonus Approach: Hide Placeholders

If we restrict our placeholders to only have a single top level element, we can actually enhance the experience for those without JavaScript even more by still sending the placeholders and then hiding them with CSS.

<style>
  template[id="B:1"] + * {
    display: none;
  }
</style>

By using a CSS selector for the original placeholder template and using the next sibling (+) operator, we can target the placeholder when the template becomes available and set it to display: none.

Writing to the Stream

We can then create a new strategy that reuses most of our approach for replacing placeholders with two key changes:

  • handlePlaceholder now creates two entries in the buffer, so that the content of the placeholder gets sent, but we have a marker in our buffer to pause at until the corresponding template becomes available.
  • substituteTemplate appends our CSS block to the template so that the placeholder content is properly hidden.
export class HidePlaceholders extends ReplacePlaceholders {
  handlePlaceholder(chunk: string, encoding: BufferEncoding, placeholder: Placeholder): void {
    if (!this.buffer.length) {
      super._write(chunk.substring(placeholder.start, placeholder.end), encoding, noop);  
    } else {
      this.buffer.push({ chunk: chunk.substring(placeholder.start, placeholder.end), encoding, callback: noop });
    }

    this.buffer.push({
      chunk: '',
      encoding,
      template: placeholder.template,
      callback: noop,
    });

  }

  substituteTemplate(template: Template): void {
    const placeholder = this.buffer.find(chunk => chunk.template === template.template);

    if (!placeholder) {
      return console.warn(`Unable to find placeholder for template: ${template.template}`);
    }

    placeholder.chunk = hidePlaceholder(template.template) + template.html;

    if (placeholder === this.buffer[0]) {
      this.releaseUntilNextPlaceholder();
    }
  }
}

Final Result of Hide Placeholders

Each piece of deferred content has its placeholder sent first, eventually being sent itself and hiding the placeholder with CSS.

The page loads as much content as possible before pausing after the first placeholder. When the template becomes available, the placeholder is hidden and the template is added in, allowing the stream to continue until the next placeholder. This continues until all the placeholders have been hidden.

Conclusion

Hopefully this article has given you a better idea of not only the value a framework like Remix can provide, but how you can build on top of its ideas to suit your own needs. Remix has done a particularly good job at exposing the right interface so that customization is easily possible in the areas that make sense.