I was building a project management application using Nuxt and a PHP API. The application had a dashboard showing multiple projects, each with its own information, tasks and status.

The problem was keeping everything up to date.

If I had the application open on two computers and updated a project on one, the other wouldn’t know anything had changed until I refreshed the page.

I wanted the application to update automatically, without having to keep refreshing it or repeatedly requesting every project from the API.

I looked at WebSockets, but decided that Server-Sent Events (SSE) would be a simpler solution.

Why Server-Sent Events?

WebSockets allow two-way communication between a browser and a server over a persistent connection.

That’s useful for applications where both sides need to send messages constantly, such as chat applications or multiplayer games.

I didn’t really need that.

The application was already sending updates to the API using normal HTTP requests. I just needed a way for the API to send updated information back to the browser.

Server-Sent Events are designed for exactly that.

The browser opens an HTTP connection to the server, which responds using a special content type:

1Content-Type: text/event-stream
Copied!

The response contains events in a simple text format.

1event: project-123
2id: event-456
3data: {"id":123,"status":"In Progress"}
Copied!

Each event has a name, an optional ID and some data.

The browser can listen for events with a particular name and update the application when one arrives.

Another useful feature is that EventSource supports reconnecting automatically when the connection is interrupted.

Creating an SSE endpoint in PHP

The backend was written in PHP using the Slim framework.

I added an endpoint which accepts a project ID, fetches the project from the database and returns it as an SSE event.

The important part of the response looks like this:

1return $response
2 ->withHeader('Content-Type', 'text/event-stream')
3 ->withHeader('Cache-Control', 'no-cache')
4 ->withHeader('Connection', 'keep-alive')
5 ->withHeader('X-Accel-Buffering', 'no')
6 ->write("event: " . $project['id'] . "\n")
7 ->write("id: " . $eventId . "\n")
8 ->write("data: " . json_encode([$project]) . "\n\n");
Copied!

The X-Accel-Buffering: no header is particularly important when using Nginx.

Without it, Nginx might buffer the response rather than forwarding events to the browser immediately.

One thing worth mentioning is that my implementation didn’t keep a PHP request open indefinitely.

It returned the current project data as a single event, then relied on EventSource reconnecting to retrieve the next update.

So although it used the SSE protocol, it behaved more like automatically reconnecting HTTP requests than a traditional persistent event stream.

It wasn’t the most efficient possible implementation, but it worked with the existing PHP API without introducing a separate WebSocket server.

Listening for events in Nuxt

On the frontend, I used the eventsource package rather than relying on the browser’s native EventSource implementation.

The reason was authentication.

Native EventSource doesn’t allow you to specify arbitrary HTTP headers when opening a connection. My API required an access token in the Authorization header.

Using an EventSource implementation which supports custom headers meant I could pass that token with the request.

1const source = new EventSourcePolyfill(
2 'https://api.example.com/projects/sse?id=123',
3 {
4 headers: {
5 Authorization: `Bearer ${accessToken}`
6 }
7 }
8)
9 
10source.addEventListener('123', (event) => {
11 const project = JSON.parse(event.data)[0]
12 
13 updateProject(project)
14})
Copied!

Each project had its own event name, based on its ID.

When an event arrived, I could update the corresponding project in Vuex without fetching the entire dashboard again.

Moving SSE into a Web Worker

As the application developed, I moved the EventSource connection into a Web Worker.

A Web Worker runs JavaScript separately from the main browser thread.

For this project, it gave me a convenient way to manage the SSE connection without putting all of the connection logic inside the Vue component.

The worker listens for messages from the application.

1import EventSourcePolyfill from 'eventsource'
2 
3let sse
4 
5onmessage = (event) => {
6 const [type, url, id, token] = event.data
7 
8 if (type === 'start') {
9 sse = new EventSourcePolyfill(url, {
10 headers: {
11 Authorization: token
12 }
13 })
14 
15 sse.addEventListener(id, (message) => {
16 postMessage(JSON.parse(message.data)[0])
17 })
18 }
19 
20 if (type === 'stop') {
21 sse?.close()
22 self.close()
23 }
24}
Copied!

The Vue component starts the worker when it needs updates and listens for messages containing the latest project data.

It can also tell the worker to stop when the component is destroyed.

I kept a fallback for browsers where Web Workers weren’t available, allowing EventSource to run directly in the application instead.

Avoiding unnecessary connections

One problem with automatically reconnecting requests is that they can carry on running when nobody is actually using the application.

If I had the dashboard open in several tabs, each tab could be requesting updates for multiple projects.

That wasn’t particularly useful.

I added logic using the Page Visibility API so the application could stop its connections when the tab was hidden.

1document.addEventListener(
2 'visibilitychange',
3 () => {
4 if (document.visibilityState === 'hidden') {
5 stopSse()
6 } else {
7 startSse()
8 }
9 }
10)
Copied!

I also staggered the connections for individual projects.

Rather than opening every connection at the same time, each project waited slightly longer based on its position in the dashboard.

1const delay = (index + 1) * 500
2 
3window.setTimeout(() => {
4 startSse()
5}, delay)
Copied!

This helped avoid a sudden burst of requests when the dashboard loaded.

Eventually, I also had to account for access tokens being renewed. The SSE connection needed to be stopped and restarted with the new token, otherwise it could continue reconnecting with credentials that were no longer valid.

Where Redis came in

EventSource has a feature called Last-Event-ID.

When a connection closes, the browser can send the ID of the last event it received when reconnecting.

I wanted to use that to recognise reconnecting requests.

I added Redis to the PHP API and started generating unique IDs for each SSE response.

1$eventId = Uuid::uuid1()->toString();
2 
3$redis->setex($eventId, 15, true);
Copied!

Each event ID was stored in Redis with a fifteen-second expiry.

The SSE response included that ID:

1->write("id: " . $eventId . "\n")
Copied!

The intention was to use the stored value to recognise a recent reconnection without repeating the full authentication process.

Redis was useful here because it offered fast, temporary storage without adding a new database table or a cleanup job.

The key automatically disappeared once its TTL expired.

Was it worth it?

The implementation became more complicated than I’d originally expected.

What started as a simple way of keeping project data updated ended up involving Web Workers, access tokens, the Page Visibility API and Redis.

But it was a useful exercise in understanding how SSE works and where it fits between normal HTTP requests and WebSockets.

For an application that only needs updates flowing from the server to the browser, SSE can be a good alternative to WebSockets.