Server Sent Events
Server sent events (SSE) are a special HTTP connection that allows to send new data to a web page at any time, by pushing messages to the web page without requiring a browser refresh. On Activations, SSE are often used as a more Next.js-friendly replacement for Websocket, as they don't require any server configuration to work.
This guide will focus on the following aspects:
- Providing the minimal code needed to set up these events so you can start sending your data.
- Offering opinionated code examples to handle them more effectively.
TIP
For a more in-depth guide we recommend you check out the MDN Server-sent events and Using server-sent events page.
NOTE
For this guide, we'll use React and Next.js in our examples, but you can use SSE with other frameworks too.
Minimal Websocket Replacement Implementation
You'll need 3 pieces to start using SSE as a replacement to Websockets
:
- A SSE endpoint to stream the events.
- A trigger/proxy that emits data to the events.
- A client connected to an event via the
EventSource
API.
The SSE Endpoint
This is a GET
endpoint with a specific set of headers to enable server-to-client messaging. You can set it up by creating a route handler inside the /src/app
folder and paste the example code.
// `src/app/api/sse/route.ts`
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
return new Response(responseStream.readable, {
headers: {
"Content-Type": "text/event-stream;charset=utf-8",
Connection: "keep-alive",
"Cache-Control": "no-cache, no-transform",
},
});
}
TIP
Although not required, we recommend you create it inside an /api
folder.
Let's break it down:
- Response headers: these are part of the SSE protocol.
- The
dynamic
variable: should be set toforce-dynamic
. Otherwise Next.js will cache the requests. - The
TransformStream
- Return the
.readable
property in theResponse
object. - Use the
.writable.getWriter()
to get an object that can write data to the stream.
- Return the
NOTE
This is the only code "required" to run SSE. With this, you could do things like integrating the Open AI API in your project.
The "Proxy"
This is the code that writes the data to the Response
using the TransformStream.writable.getWriter()
instance. We'll use a POST
route handler and a Node.js EventEmitter
to build it in our example, but you could do something different in your application.
In the same file as the SSE endpoint add the following code:
// `src/app/api/sse/route.ts`
import EventEmitter from "events";
export const dynamic = "force-dynamic";
const eventEmitter = new EventEmitter();
const EVENT_NAME = "name";
const writers: Array<WritableStreamDefaultWriter> = [];
eventEmitter.on(EVENT_NAME, (data) => {
writers.forEach((writer) => {
writer.write(`data: ${JSON.stringify(data)}\n\n`).catch((error) => {
console.error(error);
});
});
});
export async function POST(req: NextRequest) {
try {
eventEmitter.emit(EVENT_NAME, { firstname: "John", lastname: "Wick" });
// handle ok response
} catch (error) {
// handle error
}
}
export async function GET(request: Request) {
// ... GET endpoint code
writers.push(writer);
// ... response
}
Let's break it down:
- We create the following variables:
eventEmitter
: standard Node.jsEventEmitter
.EVENT_NAME
: to attach it to theeventEmitter
and send data to ourSSE
.writers
: an array to hold all the different writers of the connected clients.
- We listen for
EVENT_NAME
events on theeventEmitter
. When we receive them, we write the data to all connected SSE. - When we receive a
POST
request to our endpoint, we emit theEVENT_NAME
event to the emitter - When we get a
GET
request we push theTransformStream
writer to thewriters
array
The Client Code
Here we just need to create a useEffect
that connect to the SSE using the EventSource
API
useEffect(() => {
const eventSource = new EventSource(`/api/sse`, {
withCredentials: true,
});
eventSource.onmessage = (e) => {
/**
* Your data here
*/
console.log(JSON.parse(e.data));
};
eventSource.onerror = console.error;
const handleCloseEventSourceConnection = () => {
eventSource.close();
};
/**
* Ensures the connection is properly closed when the current window is closed.
*/
window.onbeforeunload = () => {
handleCloseEventSourceConnection();
};
return () => {
handleCloseEventSourceConnection();
};
}, []);
Recommendations
In the previous examples we simplified the code to explain the basics of this pattern. However, there're some aspects we can improve to make the code more efficient. We'll talk about them in this section
WARNING
In the following section we'll offer some libraries and code we've used before in conjunction with SSE, but you can replace them with whatever works best for you as long as you address the issues described in each section.
Handle Closed Connections
In the example we provided earlier, we stored each new writer inside an array whenever we received a new GET
connection. However, we never handled their removal when a connection is closed.
Let's fix that by using a Map:
// `src/app/api/sse/route.ts`
// ... variables declarations
const clientsMap = new Map<string, SSEClient>();
eventEmitter.on(EVENT_NAME, (data) => {
const clients = Array.from(clientsMap.values());
clients.forEach((client) => {
client.writer.write(`data: ${JSON.stringify(data)}\n\n`).catch((error) => {
if (error instanceof Error && error.name === "ResponseAborted") {
clientsMap.delete(client.id);
return;
}
console.error(error);
});
});
});
export async function GET(request: Request) {
// ... GET endpoint code
const id = crypto.randomUUID();
clientsMap.set(id, { writer, id });
// ... response
}
We use a Map
instead of an array because Map.delete
is key-based, so we don't need to find the client's position in the array to remove it. This makes the removal process more efficient.
Data Parsing
As with any other type of HTTP transfer, the data received in eventSource.onmessage
after being parsed with JSON.parse
is typed as any
, which is not particularly useful when working with typescript.
To solve this, you can use a library like zod
to:
- Ensure you're receiving the data you expect.
- Get better types to work with this data in your application.
// on the client
import z from "zod";
// ... more react code
useEffect(() => {
const eventSource = new EventSource(`/api/sse`, {
withCredentials: true,
});
eventSource.onmessage = (e) => {
const schema = z.object({
eventType: z.enum(["trigger-intro", "cms-update"]),
});
const _data = JSON.parse(e.data);
const parse = schema.safePase(_data);
if (!parse.success) {
// the data didn't match the schema
return;
}
// Now you can do:
if (parse.data.eventType === "cms-update") {
refetchPageData();
} else {
initIntroAnimation();
}
};
}, []);
You can also use it in the event:
// `data` here is typed `any`
eventEmitter.on(EVENT_NAME, (data) => {
const schema = z.object({
foo: z.string(),
});
if (!parse.success) {
// handle invalid data
return;
}
// Now you can use `parse.data` with the schema types!
});
NOTE
Overall, we strongly recommend you use zod with SSE, as their native integration with TypeScript is rather lacking.