Long-running API requests with SSE and NextJS
If you have long-running processes at the server side, you mostly limited to two options:
- Wait for response to complete, returning data at the end, and showing indefinite spinner / loader.
- Use third-party server to provide notifications during the process (e.g. Pusher). I have example of this at Interactive Rest GitHub repo.
However, with NextJS 13 and Edge runtimes, it is now possible to run SSE (server-sent events) as API endpoint, processing data, progressively returning log and completion information and in the end, feed the data to browser.
Declaring long running code
const longRunning = () => {
// Do something for 5 minutes
// Return data
}
If you have such a code, you need to improve it so it will report back to the caller its progress. For example:
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
interface Notify {
log: (message: string) => void;
complete: (data: any) => void;
error: (error: Error | any) => void;
close: () => void;
}
const longRunning = async (notify: Notify) => {
notify.log("Started")
await delay(1000)
notify.log("Done 15%")
await delay(1000)
notify.log("Done 35%")
await delay(1000)
notify.log("Done 75%")
await delay(1000)
notify.complete({ data: "My data" })
}
Now we report progress to the caller. But how we can implement this in NextJS?
Check out the Edge runtime in NextJS, which allows us to take control on how response is processed. For example, I am using SSE to send stream to client.
We are constructing a stream not the final response, so data will be send to client progressively, not with one go.
/**
* Implements long running response. Only works with edge runtime.
* @link https://github.com/vercel/next.js/issues/9965
*/
export default async function longRunningResponse(req: NextApiRequest, res: NextApiResponse) {
let responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
const encoder = new TextEncoder();
let closed = false;
// Invoke long running process
longRunning({
log: (msg: string) => writer.write(encoder.encode("data: " + msg + "\n\n")),
complete: (obj: any) => {
writer.write(encoder.encode("data: " + JSON.stringify(obj) + "\n\n")),
if (!closed) {
writer.close();
closed = true;
}
}
error: (err: Error | any) => {
writer.write(encoder.encode("data: " + err?.message + "\n\n"));
if (!closed) {
writer.close();
closed = true;
}
},
close: () => {
if (!closed) {
writer.close();
closed = true;
}
},
}).then(() => {
console.info("Done");
if (!closed) {
writer.close();
}
}).catch((e) => {
console.error("Failed", e);
if (!closed) {
writer.close();
}
});
// Return response connected to readable
return new Response(responseStream.readable, {
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/event-stream; charset=utf-8",
Connection: "keep-alive",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Content-Encoding": "none",
},
});
}
export const config = {
runtime: "edge",
};
Using this API endpoint in front-end code is simple:
const listenSSE = (callback: (event: MessageEvent<any>) => { cancel?: true } | undefined) => {
const eventSource = new EventSource("/api/mad", {
withCredentials: true,
});
console.info("Listenting on SEE", eventSource);
eventSource.onmessage = (event) => {
const result = callback(event);
if (result?.cancel) {
console.info("Closing SSE");
eventSource.close();
}
};
return {
close: () => {
console.info("Closing SSE");
eventSource.close();
},
};
};
And the resulting app can fetch and parse with complete interactivity:
Check the source code here: https://github.com/huksley/mad
About author:
Ruslan Gainutdinov is a full-stack engineer and coding architect. He is passionate about front-end and full-stack development. Do you need help writing modern SaaS? Reach out to him at his consultancy Wizecore, connect on LinkedIn or Follow on Twitter.