DEV Community

Cover image for Symfony Mercure: Server-Sent Events at Scale Without WebSockets
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Mercure: Server-Sent Events at Scale Without WebSockets


You ship a notifications feature. Sales wants live order updates in the dashboard. PMs want a little green dot when a new comment lands. Someone says "WebSockets" in the kickoff and the room nods because that's the word everyone knows.

Three weeks later you're tuning a Soketi cluster, fighting sticky-session config in your load balancer, and wondering why a feature that pushes 200 messages per minute needs its own runtime.

Mercure is the answer for most of these cases. It's a hub that speaks Server-Sent Events over plain HTTP/2, ships as a single Go binary, and integrates with Symfony through a one-line HubInterface->publish(...) call. You don't need WebSockets for 80% of "push to browser" work. You probably knew that already. What's harder is knowing where the edges are.

SSE vs WebSockets: when one-way push is enough

A WebSocket is a bidirectional TCP-ish channel over HTTP upgrade. Both sides can send anything, any time. That's strong and it costs you: every gateway, proxy, and CDN in the path needs to understand the upgrade handshake. Cloudflare does. Your enterprise customer's corporate proxy might not. Your /health checker definitely doesn't.

Server-Sent Events are the boring sibling. Plain HTTP, Content-Type: text/event-stream, the server keeps the response open and writes lines like data: {"order_id": 42}\n\n. The browser ships EventSource as a built-in API. No upgrade dance, no special framing, no extra middleware.

The trade-off: SSE is server-to-client only. If the client needs to send messages back, you do that with a regular POST request. Chat app where users type? A POST per message is cheap. Multiplayer game where every keypress matters? WebSockets win because the per-message overhead of opening a new HTTP request is real.

Rule of thumb: if more than 10% of your traffic flows client-to-server in real time, use WebSockets. Otherwise SSE is enough, and Mercure makes it easy.

Mercure's design: a hub between publishers and subscribers

Mercure is two protocols and one server.

Publishers (your Symfony app) POST updates to the hub:

POST /.well-known/mercure HTTP/1.1
Authorization: Bearer <publisher-jwt>
Content-Type: application/x-www-form-urlencoded

topic=https://example.com/orders/42&data=%7B%22status%22%3A%22shipped%22%7D
Enter fullscreen mode Exit fullscreen mode

Subscribers (the browser) open a long-lived GET to the same hub with the topics they want:

GET /.well-known/mercure?topic=https://example.com/orders/42 HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <subscriber-jwt>
Enter fullscreen mode Exit fullscreen mode

The hub fans the update out to every subscriber on that topic. Your PHP process publishes once, ten thousand connected browsers receive it. The PHP process isn't holding any connection open. That's the architectural win: long-lived connections live on a Go binary tuned for that one job, and your PHP-FPM pool stays free to do PHP-FPM things.

From Symfony, the symfony/mercure bundle gives you a HubInterface. The whole publish path is this:

<?php

namespace App\Order;

use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

final class OrderShippedNotifier
{
    public function __construct(private HubInterface $hub) {}

    public function notify(int $orderId, string $tracking): void
    {
        $update = new Update(
            topics: "https://example.com/orders/{$orderId}",
            data: json_encode([
                'order_id' => $orderId,
                'status' => 'shipped',
                'tracking' => $tracking,
            ], JSON_THROW_ON_ERROR),
            private: true, // requires subscriber JWT to include this topic
        );

        $this->hub->publish($update);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. No queue, no socket, no daemon. The publish() call is a synchronous HTTP request to the hub. Usually sub-millisecond on a co-located hub, a few ms over the network. If you want it async, dispatch it through Messenger and let a worker fire the publish.

The private: true flag matters. Without it, anyone who knows the topic URL can subscribe. With it, the subscriber's JWT must explicitly authorize this exact topic. Most production setups want every topic private.

Authorization: JWT topic-level claims

This is where Mercure gets interesting and where most tutorials stop short.

The hub trusts JWTs signed with a shared secret. Inside the JWT, two claims matter:

  • mercure.publish: list of topic patterns this token can publish to.
  • mercure.subscribe: list of topic patterns this token can subscribe to.

Topic patterns support URI templates per RFC 6570. So you can issue a JWT that lets a user subscribe to https://example.com/users/{id}/notifications where {id} is bound to their user ID, and nothing else.

In Symfony, you don't usually mint JWTs by hand. The bundle does it via Authorization cookies:

<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

final class DashboardController extends AbstractController
{
    #[Route('/dashboard', name: 'dashboard')]
    public function index(Authorization $authorization): Response
    {
        $user = $this->getUser();
        $userId = $user->getId();

        $response = $this->render('dashboard/index.html.twig');

        // sets a HttpOnly, Secure cookie called "mercureAuthorization"
        // the browser's EventSource sends it automatically
        $authorization->setCookie(
            $response,
            subscribe: [
                "https://example.com/users/{$userId}/notifications",
                "https://example.com/orders/{user_id}", // bound below
            ],
            additionalClaims: [
                'mercure' => [
                    'payload' => ['user_id' => $userId],
                ],
            ],
        );

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

The cookie is HttpOnly and scoped to the hub's domain. The browser will never expose the JWT to your JS. When EventSource connects, the cookie rides along automatically. No bearer tokens in URLs, no localStorage exposure to XSS.

Gotcha: the cookie's Domain must match the hub's domain, and SameSite defaults bite hard if the hub is on a subdomain. If your app is at example.com and the hub is at mercure.example.com, set MERCURE_PUBLIC_URL and the cookie domain to .example.com, not the subdomain. Otherwise the cookie won't be sent and the hub returns 401 with a message that doesn't tell you why. Two senior teams can burn a full afternoon on this before they spot it.

Hub Caddy config

Mercure's reference hub is a Caddy module. A minimal Caddyfile looks like this:

{
    # https://caddyserver.com/docs/caddyfile/options
    auto_https off
    admin off
    log {
        level INFO
    }
}

:80 {
    encode zstd gzip

    mercure {
        # JWT secret: must match what your Symfony app signs with
        publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_JWT_ALG}
        subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_JWT_ALG}

        # CORS: every browser origin that opens EventSource
        cors_origins https://app.example.com https://admin.example.com

        # topic publishers must use https URIs that match these patterns
        publish_origins https://app.example.com

        # tune for your traffic shape; see "scaling" below
        write_timeout 600s
        dispatch_timeout 5s

        # the "anonymous" toggle: usually false in production
        anonymous false

        # bolt = single-node persistence for missed messages
        # for multi-node, swap to "transport_url redis://..."
        transport_url "bolt:///data/mercure.db?size=1000&cleanup_frequency=0.3"
    }

    route /.well-known/mercure* {
        mercure
    }

    respond /healthz "ok" 200
}
Enter fullscreen mode Exit fullscreen mode

Set MERCURE_PUBLISHER_JWT_KEY and MERCURE_SUBSCRIBER_JWT_KEY in your env. They can be the same secret if you want; the distinction lets you rotate one without the other. MERCURE_JWT_ALG is usually HS256.

The transport_url is the part that bites people. bolt:// is local disk: fine for one hub, broken the second you run two. For multi-hub setups, use redis:// or any other Mercure-supported transport. The hubs subscribe to the same channel; publishing to any hub fans the message out to every connected browser on every hub.

EventSource browser-side code

The client is unsurprising and that's the point:

<script>
  const userId = 42;
  const url = new URL("https://mercure.example.com/.well-known/mercure");
  url.searchParams.append(
    "topic",
    `https://example.com/users/${userId}/notifications`
  );
  url.searchParams.append(
    "topic",
    `https://example.com/orders/${userId}`
  );

  // withCredentials sends the HttpOnly mercureAuthorization cookie
  const es = new EventSource(url, { withCredentials: true });

  es.addEventListener("message", (event) => {
    const payload = JSON.parse(event.data);
    // route by topic embedded in payload, or use named events
    handleUpdate(payload);
  });

  es.addEventListener("error", (event) => {
    // EventSource auto-reconnects; log if you care about gaps
    console.warn("mercure connection blip", event);
  });
</script>
Enter fullscreen mode Exit fullscreen mode

EventSource reconnects automatically with exponential backoff. On reconnect, the browser sends a Last-Event-ID header, and Mercure replays anything you missed, if the transport is bolt or redis with retention configured. That's the killer feature people miss: a flaky mobile connection drops, reconnects, and the user sees the updates they would have missed. With raw WebSockets you write that bookkeeping yourself.

If you need named events (different handlers for different message types), publish with the type argument:

$update = new Update(
    topics: "https://example.com/orders/{$orderId}",
    data: json_encode($payload),
    type: 'order.shipped', // arrives as event.type on the client
);
$this->hub->publish($update);
Enter fullscreen mode Exit fullscreen mode

Then bind on the client:

es.addEventListener("order.shipped", (event) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

Cleaner than parsing a type field out of the payload.

Scaling: multiple hubs, sticky sessions, why this is easier than WS

Here's the part that sells Mercure to ops.

A WebSocket gateway typically needs sticky sessions because the gateway holds in-memory state for each connection. Two browsers on two gateway nodes can't see each other's messages unless you bolt on a pub/sub layer (Redis, NATS) and write the fan-out yourself. Your load balancer needs ip_hash or a cookie-based stickiness rule. Auto-scaling becomes a project.

Mercure builds that in. Multiple hubs share a transport_url. A browser connected to hub A receives a publish sent to hub B. Your load balancer can round-robin; there's no stickiness required. To add capacity you start another hub container, point it at the same Redis, done.

For ~10k concurrent subscribers per hub, one Caddy-Mercure container on a small box is fine. The Go runtime doesn't blink at long-lived idle connections; that's its strength. Past that, scale horizontally by adding hubs. The PHP side scales the way it always has, because PHP isn't holding connections.

Real gotcha: the connection limit is set by your OS file descriptors and your load balancer's per-pod max connections. The default ulimit -n of 1024 will bite you at exactly the worst moment. Set it to at least 65536 on the hub. AWS ALB has a max of about 100k concurrent connections per ALB node, which sounds like a lot until your B2B SaaS lands a customer with 50k seats.

Backpressure when subscribers are slow

A slow subscriber on a hub-and-spoke push system is a buffer that grows. Mercure's write_timeout config caps how long the hub waits to flush a message to a single subscriber. Default is 600s (10 minutes), which is too generous in most apps.

If a mobile client is on a flaky 3G connection and can't drain the buffer, the hub eventually disconnects them. They reconnect, replay via Last-Event-ID, and life continues. The trap is when you publish enormous payloads. A 500KB JSON update times ten thousand slow subscribers fills hub memory fast.

Rule: keep individual updates under 4KB. If you need to send more, send a small "something changed for X" update and let the client fetch the full payload from your regular API. That gives you HTTP cache headers, range requests, and CDN caching for the heavy bytes. Mercure carries the notification, not the data.

Using Mercure from Laravel

The hub doesn't know what framework you're publishing from. There's no official Laravel bundle, but the publish is one HTTP call. You can use symfony/mercure directly in a Laravel project; Composer doesn't care:

<?php

namespace App\Notifications;

use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\Jwt\StaticTokenProvider;
use Symfony\Component\Mercure\Update;

final class OrderNotifier
{
    private Hub $hub;

    public function __construct()
    {
        $this->hub = new Hub(
            url: config('mercure.hub_url'),
            jwtProvider: new StaticTokenProvider(config('mercure.publisher_jwt')),
        );
    }

    public function shipped(int $orderId): void
    {
        $this->hub->publish(new Update(
            topics: "https://example.com/orders/{$orderId}",
            data: json_encode(['status' => 'shipped']),
            private: true,
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

Bind it as a singleton in a service provider and you're done. For minting subscriber JWTs in Laravel, lcobucci/jwt (already in Laravel's Sanctum dependency tree) does the job: sign with the shared secret, embed mercure.subscribe claims, drop in an HttpOnly cookie scoped to the hub domain.

When to reach for WebSockets instead

Mercure isn't the right answer for everything. Pick WebSockets when:

  • The client sends frequent low-latency messages back. Live cursor tracking, multiplayer game state, voice/video signaling.
  • You need true peer-to-peer routing via the server. SSE is broadcast-with-filters; if you need "send this to user X specifically with sub-50ms latency on both directions," WebSockets give you a tighter loop.
  • You're building on top of a protocol that already runs over WebSocket: STOMP for traditional message queues, GraphQL subscriptions, MQTT-over-WS for IoT.

Everything else (notifications, dashboard updates, comment streams, presence indicators, live log tailing, chat where typing is rare relative to reading) runs cheaper on SSE through Mercure. Less to run, less to operate, less to debug. The protocol is curl-able. That alone is worth a lot when something breaks at 2am.

What's your current real-time stack? Have you tried SSE in production, or are you still defaulting to WebSockets out of habit?


If this was useful

This post is one slice of how to keep your real-time layer thin: a hub does the connection-holding, your app stays a normal request/response unit, and the boundary between them is one publish() call. Codebases that scale past their framework's defaults reach for that kind of seam everywhere: push, jobs, payments, integrations. Decoupled PHP is the architectural layer your codebase wants once "we'll just call the SDK from the controller" stops working.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)