Search code examples
next.jshttpresponse

Sequential user feedback from next.js api call


I have a long running process (~30sec) that calls several external apis. That process should run on the server in my next.js app.

I created an /api route and things work, I however want to give the user feedback on the process that is composed of several subtasks. E.g. the user should see:

  • processing task 1
  • processing task 2
  • ...
  • finished (with data callback)

I tried several ways to do it:

  1. Streams:

api/hello.js

export default async function handler(req, res) {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.write(JSON.stringify({ data: "Step1" }));
  res.write(JSON.stringify({ data: "Step2" }));
  res.write(JSON.stringify({ data: "Step3" }));
  res.end();
}

client

      const response = await fetch("/api/hello", {
        method: "POST",
        body: JSON.stringify(config),
        keepalive: true,
        mode: "cors",
      });

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

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        console.log('Received:', decoder.decode(value));
      }

      console.log("Response fully received");

The above code does output

received {"data":"Step1"}{"data":"Step2"}{"data":"Step3"} "Response fully received"

I was expecting that chunks are logged after each other and not at once?

  1. The other way was is putting all subprocesses it all in separate API routes and calling all sequentially with the result data from the one before. But that seems complicated.

How can I fix 1) or is 2) suited or is there an option 3) I am not seeing?


Solution

  • I think to use server-sent events you need to use text/event-stream MIME type instead. I just made a quick reproduction locally and it works just fine. I could not make a Codesandbox repro because it's extremely laggy for me after an update, but I will add a link here if it starts to work.

    Basically I just used these 2 files:

    pages/api/endpoint.ts:

    import type { NextApiRequest, NextApiResponse } from "next";
    
    export default function handler(req: NextApiRequest, res: NextApiResponse) {
      res.writeHead(200, {
        Connection: "keep-alive",
        // I saw somewhere that it needs to be set to 'none' to work in production on Vercel
        "Content-Encoding": "none",
        "Cache-Control": "no-cache",
        "Content-Type": "text/event-stream",
      });
    
      let count = 0;
      res.write(`${JSON.stringify({ step: 0 })}`);
    
      const intervalId = setInterval(() => {
        count++;
        res.write(`${JSON.stringify({ step: count })}`);
    
        if (count === 100) {
          clearInterval(intervalId);
          res.end();
        }
      }, 5000);
    
      res.on("close", () => {
        clearInterval(intervalId);
        res.end();
      });
    }
    

    And this is a client component for Next.js 13:

    "use client";
    
    import { useEffect, useState } from "react";
    
    export function ClientComponent() {
      const [state, setState] = useState({ step: 0 });
    
      useEffect(() => {
        const callApi = async () => {
          const data = await fetch("/api/endpoint", { method: "post" });
    
          const stream = data.body;
    
          if (!stream) {
            return;
          }
    
          for await (const message of streamToJSON(stream)) {
            setState(JSON.parse(message));
          }
        };
    
        callApi();
      }, []);
    
      return (
        <main>
          <div>Current step is: {state.step}</div>
        </main>
      );
    }
    
    async function* streamToJSON(
      data: ReadableStream<Uint8Array>
    ): AsyncIterableIterator<string> {
      const reader = data.getReader();
      const decoder = new TextDecoder();
    
      while (true) {
        const { value, done } = await reader.read();
        if (done) {
          break;
        }
    
        if (value) {
          try {
            yield decoder.decode(value);
          } catch (error) {
            console.error(error);
          }
        }
      }
    }