Skip to content

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:

  1. Providing the minimal code needed to set up these events so you can start sending your data.
  2. 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:

  1. A SSE endpoint to stream the events.
  2. A trigger/proxy that emits data to the events.
  3. 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.

typescript
// `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 to force-dynamic. Otherwise Next.js will cache the requests.
  • The TransformStream
    • Return the .readable property in the Response object.
    • Use the .writable.getWriter() to get an object that can write data to the stream.

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:

typescript
// `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:

  1. We create the following variables:
    • eventEmitter: standard Node.js EventEmitter.
    • EVENT_NAME: to attach it to the eventEmitter and send data to our SSE.
    • writers: an array to hold all the different writers of the connected clients.
  2. We listen for EVENT_NAME events on the eventEmitter. When we receive them, we write the data to all connected SSE.
  3. When we receive a POST request to our endpoint, we emit the EVENT_NAME event to the emitter
  4. When we get a GET request we push the TransformStream writer to the writers array

The Client Code

Here we just need to create a useEffect that connect to the SSE using the EventSource API

typescript
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:

typescript
// `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:

  1. Ensure you're receiving the data you expect.
  2. Get better types to work with this data in your application.
typescript
// 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:

typescript
// `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.