Will Bunting

Streaming UX Updates in Next.js

October 17, 2024 (1d ago)

With more and more AI applications hitting the web, the "streaming UX updates" pattern has become increasingly popular. There are some very new ways to do this in React (with streaming React Server Components). But there are actually a large number of ways to stream updates to the browser. Here we will review a handful of the different approaches and their pros and cons.

Our test application will be a sitemap.xml validator (you can see it at sitemap.run). We want to stream all the progress of validation since it is a long-running process.

But first, why might you want to do this?

For AI applications, the use case for streaming is clear—you can start to see the AI response immediately. But for regular applications, you might end up with user actions that trigger long-running processes, and giving the user updates as those processes go along is a great UX because it ensures that the user doesn't feel like the site is "broken" when loading takes a long time.

A reason to use streaming for such kinds of updates compared with polling is because polling requires client-side logic not only to do the rendering but also to fetch various steps on the server. This tends to be very bug-prone because changes to the server need to always be very carefully coupled to the client-side logic for fetching each step in a process.

Let's explore different approaches to streaming updates in Next.js, along with their pros and cons.

Server-Sent Events (SSE)

One of the oldest ways around on the web to stream updates to the browser is Server-Sent Events (SSE).

Pros:

  • Simple to Implement: SSE uses standard HTTP connections and is relatively straightforward to set up.
  • Automatic Reconnection: The browser will automatically try to reconnect if the connection is lost.
  • Efficient for Unidirectional Data Flow: Ideal for scenarios where the server needs to push updates to the client without requiring messages from the client.
  • Lightweight: SSE connections are lightweight compared to WebSockets, with less overhead.

Cons:

  • Unidirectional Communication: SSE only allows server-to-client communication. If you need bidirectional communication, SSE won't suffice.
  • Limited Browser Support: While most modern browsers support SSE, some older browsers like Internet Explorer do not.
  • Connection Limits: Browsers limit the number of concurrent SSE connections per domain, which can be problematic for applications requiring multiple streams.
  • No Binary Data Support: SSE can only send text data, not binary.

Since our focus is on streaming updates for a long-running process, SSE can be a good fit. However, setting up SSE requires setting appropriate headers and managing the event stream. For simplicity, we'll focus on other methods in this article.

WebSockets

WebSockets are a super common way to have real-time updates from a server streamed to the client.

One big downside with WebSockets is that they tend not to play nicely with newer serverless infrastructure providers (because of the way that they need to always be listening for two-way communication).

This makes WebSockets not a great fit for what we are trying to demo (long server processes updating a client). But they are still a great fit for some things (and the only way to do certain things) like real-time multiplayer collaboration, games, etc.

Pros:

  • Bidirectional Communication: Allows for full-duplex communication between the client and server.
  • Real-Time Data Transfer: Ideal for applications requiring immediate data exchange.
  • Efficient After Connection: Once the connection is established, data can be sent back and forth with minimal overhead.

Cons:

  • Complexity: More complex to implement and manage compared to SSE or HTTP streaming.
  • Not Serverless-Friendly: Requires persistent connections, which can be challenging with serverless architectures like AWS Lambda or Vercel Functions.
  • Scalability Issues: Managing a large number of concurrent WebSocket connections can be resource-intensive.
  • Firewall and Proxy Issues: Some firewalls and proxies might block WebSocket connections, affecting reliability.

For applications requiring real-time bidirectional communication, such as chat apps or collaborative tools, WebSockets are a great fit. However, for our sitemap validator, the overhead and complexity of WebSockets are unnecessary.

Streaming HTTP

Here's an example of how to send and receive updates with simple HTTP streams.

Pros:

  • Simple and Standard: Uses standard HTTP protocols, making it easy to implement and debug.
  • Compatible with Serverless: Works well with serverless functions and modern cloud infrastructure.
  • Low Overhead: No need for complex protocols or persistent connections.

Cons:

  • Unidirectional: Similar to SSE, it's primarily for server-to-client communication.
  • Manual Handling: Requires manual parsing of streamed data on the client side.
  • Browser Support: Relies on browser APIs like ReadableStream, which may not be supported in older browsers.
  • Error Handling Complexity: Managing errors and reconnection logic can be more involved.

Client Code:

"use client";

import React from "react";

export function useStream(url: string, method = "POST") {
  const [data, setData] = React.useState<{ message: string }[]>([]);
  const [loading, setLoading] = React.useState(false);

  const startStream = async () => {
    setLoading(true);
    setData([]);

    const response = await fetch(url, { method });

    if (!response.body) {
      setLoading(false);
      return;
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let partial = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      partial += decoder.decode(value, { stream: true });
      const lines = partial.split("\n");

      lines.slice(0, -1).forEach((line) => {
        if (line.trim()) {
          try {
            setData((prevData) => [...prevData, JSON.parse(line)]);
          } catch (err) {
            console.error("Error parsing JSON:", err);
          }
        }
      });

      partial = lines[lines.length - 1];
    }

    setLoading(false);
  };

  return { data, loading, startStream };
}

const StreamingComponent = () => {
  const { data, loading, startStream } = useStream("/api/stream");

  return (
    <div>
      <button onClick={startStream} disabled={loading}>
        Start Validation
      </button>
      <div>
        {data.map((chunk, index) => (
          <div key={index}>{chunk.message}</div>
        ))}
      </div>
    </div>
  );
};

Server Code:


// api/stream.ts

// Function to simulate sitemap validation steps
async function simulateValidation(
  controller: ReadableStreamDefaultController,
  encoder: TextEncoder
) {
  const steps = [
    "Fetching sitemap...",
    "Parsing XML...",
    "Validating URLs...",
    "Checking for errors...",
    "Validation complete."
  ];

  for (const step of steps) {
    // Simulate delay
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // Stream each step as JSON
    controller.enqueue(
      encoder.encode(
        JSON.stringify({
          message: step
        }) + "\n"
      )
    );
  }

  controller.close(); // End the stream
}

// The POST handler for processing and streaming the validation results
export async function POST(request: Request) {
  const encoder = new TextEncoder();

  // Create a readable stream
  const readableStream = new ReadableStream({
    async start(controller) {
      // Start the simulated validation and stream updates
      await simulateValidation(controller, encoder);
    }
  });

  // Return the streaming response
  return new Response(readableStream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache",
      Connection: "keep-alive"
    }
  });
}

In this example, we simulate a long-running validation process by streaming updates to the client as each step completes. The client reads from the stream and updates the UI in real-time.

Streaming React Components

The newest method of streaming updates in a Next.js application is using React Server Components to directly stream components from the server. This can yield some amazing results in the sense that the server can continuously handle the rendering of the components, and all the client has to do is pick up the already rendered components and insert them into the React tree.

This has the advantage of making the client side as simple as possible—no fetching logic really, just rendering the result of a Server Action.

Pros:

  • Seamless Integration: Deep integration with React and Next.js provides a seamless developer experience.
  • Improved Performance: Streaming components can improve perceived performance by rendering parts of the UI as soon as they're ready.
  • Simplified Client Code: Reduces the need for complex client-side state management and data fetching logic.
  • Server-Side Benefits: Leverages server-side rendering for better SEO and initial load performance.

Cons:

  • Complex Setup: Can be complex to set up and may require understanding new React features like Server Components.
  • Limited Ecosystem Support: As a newer feature, it might have less community support and fewer learning resources.
  • Server Dependency: Relies on server-side rendering, which might not fit all architectures, especially static sites.
  • Debugging Challenges: Debugging asynchronous server-rendered components can be more challenging.

Client Code:

"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { streamComponent } from "./actions";

export default function Home() {
  const [component, setComponent] = useState<React.ReactNode>();

  return (
    <div className="min-h-screen p-8 pb-20">
      <main className="flex flex-col items-center gap-8">
        <form
          onSubmit={async (e) => {
            e.preventDefault();
            setComponent(await streamComponent());
          }}
        >
          <Button>Start Validation</Button>
        </form>
        <div>{component}</div>
      </main>
    </div>
  );
}

Server Code:

"use server";

import streamReact from "@/lib/streamable-ui/stream-react";

// Simulate a loading component
const LoadingComponent = () => (
  <div className="animate-pulse p-4">Validating sitemap...</div>
);

// Simulate the validation result component
const ValidationResult = ({ result }: { result: string }) => (
  <div className="p-4">
    <h2>Validation Complete</h2>
    <p>{result}</p>
  </div>
);

// Simulate a long-running validation function
const validateSitemap = async () => {
  await new Promise((resolve) => setTimeout(resolve, 4000));
  return "Sitemap is valid!";
};

export async function streamComponent() {
  const result = await streamReact(async function* () {
    // Initially render the loading component
    yield <LoadingComponent />;
    // Perform the validation
    const validationResult = await validateSitemap();
    // Render the validation result
    return <ValidationResult result={validationResult} />;
  });

  // Return the streamed component
  // @ts-expect-error (TypeScript may complain, but it's okay here)
  return result.value;
}

Stream React Utility:

// lib/streamable-ui/stream-react.ts

import { ReactNode } from "react";
import { createStreamableUI } from "./create-streamable-ui";

type Streamable = ReactNode | Promise<ReactNode>;
type Renderer = () =>
  | Streamable
  | Generator<Streamable, Streamable, void>
  | AsyncGenerator<Streamable, Streamable, void>;

const streamReact = async (generate: Renderer) => {
  // Initialize the UI stream
  const ui = createStreamableUI(null);

  async function render({
    renderer,
    streamableUI,
    isLastCall = false
  }: {
    renderer: Renderer | undefined;
    streamableUI: ReturnType<typeof createStreamableUI>;
    isLastCall?: boolean;
  }) {
    if (!renderer) return;

    const rendererResult = renderer();

    if (isAsyncGenerator(rendererResult) || isGenerator(rendererResult)) {
      while (true) {
        const { done, value } = await rendererResult.next();
        const node = await value;

        if (isLastCall && done) {
          streamableUI.done(node);
        } else {
          streamableUI.update(node);
        }

        if (done) break;
      }
    } else {
      const node = await rendererResult;

      if (isLastCall) {
        streamableUI.done(node);
      } else {
        streamableUI.update(node);
      }
    }
  }

  try {
    // Render the initial call
    await render({
      renderer: generate,
      streamableUI: ui,
      isLastCall: true
    });
  } catch (error) {
    ui.error(error);
  }

  return {
    // Return the final rendered value
    // @ts-expect-error (TypeScript may complain, but it's okay here)
    value: ui.value
  };
};

function isAsyncGenerator(value: any): value is AsyncGenerator {
  return value && typeof value[Symbol.asyncIterator] === "function";
}

function isGenerator(value: any): value is Generator {
  return value && typeof value[Symbol.iterator] === "function";
}

export default streamReact;

In this approach, we utilize React Server Components and Server Actions to stream components directly from the server. The client side simply renders the component returned by the server, with minimal client-side logic.

Conclusion

Streaming updates to the client can significantly enhance the user experience, especially for applications involving long-running processes or real-time data. We've explored several methods to achieve streaming in Next.js applications:

Server-Sent Events: Simple and efficient for unidirectional data flow but lacks bidirectional communication. WebSockets: Powerful for real-time, bidirectional communication but can be complex and less compatible with serverless architectures. Streaming HTTP: Uses standard HTTP protocols, making it straightforward but requires manual handling of streamed data. Streaming React Components: Leverages the latest features in React and Next.js for a seamless developer experience but may involve a steeper learning curve.

Choosing the right method depends on your application's specific needs and the infrastructure you're using. For our sitemap validator, using streaming HTTP or Streaming React Components provides a good balance between simplicity and functionality.

By focusing on the differences in streaming methods and streamlining code examples to highlight the streaming logic, we hope this guide helps you implement streaming updates in your Next.js applications.