Real-Time Features With Symfony Mercure: A Production Alternative to WebSockets
Most teams add real-time features to their applications the hard way. They reach for WebSockets, stand up a dedicated Node.js service, wire up Socket.io, discover that their PHP backend cannot natively speak the protocol, and spend two weeks building a fragile bridge between their Symfony application and their new real-time layer. Then they spend the next six months maintaining it.
Symfony Mercure exists because this is the wrong path for most applications. Mercure is an open protocol built on server-sent events (SSE), integrated natively into Symfony and API Platform, and designed to handle exactly the use cases that send teams toward WebSocket complexity in the first place: live dashboards, notifications, presence indicators, collaborative editing, feed updates. If your real-time requirements fit a server-to-client push model—and most of them do—Mercure gets you to production faster, with less infrastructure, and with dramatically less operational surface area.
This post walks through a realistic production architecture: how Mercure works, what it is genuinely good at, where WebSockets are still the right call, and how to handle auth, scaling, and backpressure before your first real user hits your system.
How Mercure Works (and Why It Is Different from WebSockets)
WebSockets are bidirectional. Client and server can both push messages over a persistent connection at any time. That flexibility is genuinely useful for applications where the client needs to send high-frequency data to the server—game state, collaborative cursors, audio—but it comes at a cost: your backend needs to maintain open socket connections, your load balancer needs sticky sessions or a shared pub/sub layer, and every standard HTTP middleware (authentication, rate limiting, caching) needs to be either bypassed or reimplemented for the WS layer.
Mercure uses server-sent events under the hood, which means it is unidirectional: the server pushes to the client, and the client communicates back via normal HTTP requests. This is not a limitation—it is a feature. SSE is HTTP. It works through proxies, CDNs, and corporate firewalls that block WebSocket upgrades. Clients reconnect automatically after network drops. The browser's EventSource API handles reconnection, gap-filling with the Last-Event-ID header, and content type negotiation without any client-side library.
The Mercure Hub is the piece that makes this scalable. Your Symfony application publishes updates to the Hub (an HTTP POST). The Hub broadcasts those updates to all subscribed clients over their SSE connections. Your PHP application is stateless—it does not hold open connections—so it scales horizontally without any coordination. The Hub handles the fan-out.
Browser ──SSE subscription──▶ Mercure Hub ◀──POST update── Symfony App
Browser ──SSE subscription──▶ Mercure Hub
Browser ──SSE subscription──▶ Mercure Hub
The official Mercure Hub is written in Go and ships as a single binary. There is a managed hosted version, a Dockerfile, and a Symfony binary that embeds the Hub for local development. In production, you run the Hub as a separate service—typically one instance behind a load balancer, or a small cluster if you need redundancy.
Installing and Configuring Mercure in a Symfony Application
Install the Symfony Mercure component:
composer require symfony/mercure-bundle
Configure the Hub URL and JWT secret in your environment:
# .env.local (never commit secrets)
MERCURE_URL=http://mercure-hub:3000/.well-known/mercure
MERCURE_PUBLIC_URL=https://yourdomain.com/.well-known/mercure
MERCURE_JWT_SECRET=your-very-long-random-secret-at-least-256-bits
In config/packages/mercure.yaml:
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
That is the full server-side setup. The bundle handles JWT generation for your publish requests and provides the HubInterface service for publishing updates.
Publishing Updates From Your Application
The Symfony Mercure bundle gives you a HubInterface that you inject wherever you need to publish. Topics are URI templates—they do not need to resolve to anything, they are just identifiers:
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
class OrderService
{
public function __construct(
private readonly HubInterface $hub,
private readonly OrderRepository $orders,
) {}
public function markShipped(string $orderId): void
{
$order = $this->orders->find($orderId);
$order->ship();
$this->orders->save($order);
// Publish to a per-order topic
$this->hub->publish(new Update(
topics: "https://yourdomain.com/orders/{$orderId}",
data: json_encode([
'status' => 'shipped',
'tracking' => $order->getTrackingNumber(),
'updated_at' => $order->getUpdatedAt()->format(\DateTimeInterface::ATOM),
]),
));
}
}
The Update constructor accepts a single topic string or an array of topics. Publishing to multiple topics lets one broadcast reach subscribers watching different topic patterns—useful for broadcasting to a user's global notification feed and to a specific order page simultaneously:
$this->hub->publish(new Update(
topics: [
"https://yourdomain.com/orders/{$orderId}",
"https://yourdomain.com/users/{$userId}/notifications",
],
data: json_encode($payload),
));
For high-throughput applications, publish from a Symfony Messenger async handler rather than inline in your request cycle. This keeps your HTTP response times fast and gives you retry logic if the Hub is temporarily unavailable.
Subscribing on the Client Side
The browser's built-in EventSource API handles the SSE connection. For public topics, the subscription URL is just the Hub URL with a topic query parameter:
const url = new URL('https://yourdomain.com/.well-known/mercure');
url.searchParams.append('topic', 'https://yourdomain.com/orders/12345');
const es = new EventSource(url);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
updateOrderStatus(data.status, data.tracking);
};
es.onerror = (error) => {
// EventSource reconnects automatically—this fires on connection drops,
// not just fatal errors. Log but do not panic.
console.warn('Mercure connection interrupted, reconnecting...', error);
};
EventSource reconnects automatically after network interruptions. When it reconnects, it sends the Last-Event-ID header with the ID of the last event it received, and the Hub replays any missed events from its history. This gap-filling is built into the protocol—you get it for free.
For React or Next.js frontends, wrap this in a custom hook:
import { useEffect, useRef } from 'react';
export function useMercure<T>(
topic: string,
onMessage: (data: T) => void,
): void {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
const url = new URL(process.env.NEXT_PUBLIC_MERCURE_URL!);
url.searchParams.append('topic', topic);
const es = new EventSource(url, { withCredentials: true });
es.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as T);
};
return () => es.close();
}, [topic]);
}
Securing Private Topics With JWT Authorization
Public topics work without authentication. For private data—per-user notifications, admin dashboards, internal order state—you need JWT-based authorization.
The Mercure Hub validates two JWTs: a publisher JWT (used by your Symfony application to post updates, handled automatically by the bundle) and a subscriber JWT (sent by the browser to authorize which topics it can subscribe to). You generate subscriber JWTs in your Symfony controllers and pass them to the frontend:
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
class DashboardController extends AbstractController
{
public function __construct(
private readonly string $mercureJwtSecret,
) {}
#[Route('/dashboard', name: 'dashboard')]
public function index(): Response
{
$user = $this->getUser();
// Build a JWT that allows subscribing to this user's topics only
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($this->mercureJwtSecret),
);
$token = $config->builder()
->withClaim('mercure', [
'subscribe' => [
"https://yourdomain.com/users/{$user->getId()}/*",
],
])
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->render('dashboard/index.html.twig', [
'mercure_token' => $token,
'mercure_url' => $this->getParameter('mercure.public_url'),
]);
}
}
On the client, pass the token as a cookie (the recommended approach—the Hub validates it automatically) or as an Authorization header. The cookie approach is simpler for browser clients:
// Set the token as a cookie before opening the EventSource
document.cookie = `mercureAuthorization=${mercureToken}; path=/.well-known/mercure; SameSite=Strict; Secure`;
const url = new URL(mercureUrl);
url.searchParams.append('topic', `https://yourdomain.com/users/${userId}/notifications`);
const es = new EventSource(url, { withCredentials: true });
Scope JWT claims tightly. A subscriber JWT should allow only the specific topic patterns that user is authorized to see. A JWT that allows * is a security hole—anyone who obtains it can subscribe to any topic.
Presence: Who Is Online Right Now
Presence is one of the most-requested real-time features and one of the most-abused ones. The naive approach—publishing a heartbeat from every connected client every few seconds—generates enormous fan-out at any meaningful user count.
A better pattern uses the Mercure subscription discovery endpoint. The Hub exposes /.well-known/mercure/subscriptions, which lists active subscriptions. You can poll this endpoint from your Symfony backend at a reasonable cadence (every 10–30 seconds) and cache the result rather than treating presence as a stream.
For lightweight presence in a SaaS context—"show who is viewing this document"—publish presence updates from the client through your Symfony backend:
#[Route('/documents/{id}/presence', methods: ['POST'])]
public function trackPresence(string $id, Request $request): JsonResponse
{
$user = $this->getUser();
$this->hub->publish(new Update(
topics: "https://yourdomain.com/documents/{$id}/presence",
data: json_encode([
'user_id' => $user->getId(),
'name' => $user->getDisplayName(),
'avatar' => $user->getAvatarUrl(),
'action' => 'active', // or 'left'
]),
private: true,
));
return new JsonResponse(['ok' => true]);
}
Call this endpoint via a beacon on tab close to send a left event even when the browser is unloading:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon(`/documents/${docId}/presence`, JSON.stringify({ action: 'left' }));
}
});
Scaling the Mercure Hub
The default Mercure Hub uses in-memory storage for subscriptions, which means a single instance. For production with any meaningful load, you have two options.
Option 1: Single Hub with Redis. The official Hub supports a Redis transport for subscription storage and pub/sub. This lets you run multiple Hub instances behind a load balancer, all sharing state through Redis:
# mercure Hub configuration (not Symfony config)
transport_url: redis://redis:6379
This handles tens of thousands of concurrent SSE connections without coordination problems.
Option 2: Managed Mercure. Mercure.rocks offers a managed hosted Hub that handles scaling, TLS, and connection limits. For most SaaS teams below 50,000 concurrent users, this is the correct choice—it eliminates the Hub as an operational concern entirely and the cost is negligible relative to your engineering time. Wolf-Tech typically recommends this for clients launching new SaaS features: get the integration right first, optimize infrastructure later.
For SSE connections themselves: modern Linux kernels handle hundreds of thousands of concurrent long-polling connections efficiently. The bottleneck is almost always the Hub's pub/sub throughput, not the connection count. Profile before over-engineering.
Backpressure and Slow Clients
SSE connections stay open. A client on a slow mobile connection or with a congested network will consume Hub resources indefinitely if you do not have backpressure handling.
The Hub's built-in approach is to buffer messages per subscription up to a configurable limit and drop the oldest ones when the buffer fills. Configure this via the Hub's max_events_to_persist setting.
On the Symfony side, for high-throughput topics (events that fire many times per second), batch updates rather than publishing every individual state change. A live dashboard showing aggregate metrics does not need to update 100 times per second—publish a rolled-up snapshot every 500ms instead:
// In a Symfony Messenger handler processing high-frequency events:
class MetricsAggregatorHandler implements MessageHandlerInterface
{
private array $pending = [];
public function __invoke(MetricEvent $event): void
{
$this->pending[] = $event;
// Flush every 500ms via a separate scheduler,
// or use a simple counter-based flush for predictability
if (count($this->pending) >= 50) {
$this->flush();
}
}
private function flush(): void
{
$summary = $this->aggregate($this->pending);
$this->hub->publish(new Update(
topics: 'https://yourdomain.com/metrics/live',
data: json_encode($summary),
));
$this->pending = [];
}
}
When WebSockets Are Still the Right Choice
Mercure covers the large majority of real-time requirements in B2B SaaS. But there are legitimate cases where you genuinely need bidirectional streaming that does not route through HTTP:
High-frequency bidirectional data. Multiplayer games, collaborative whiteboarding with cursor sync at 60fps, real-time audio or video streams. The overhead of routing every client action through an HTTP POST is too high.
Sub-50ms latency requirements. Mercure's latency is typically 50–200ms through the Hub. For trading platforms, live auctions with tight timing windows, or real-time sensor monitoring, that may be insufficient.
Full-duplex protocol tunneling. If you are tunneling a binary protocol or an existing socket-based integration, SSE cannot carry the payload.
If you find yourself in one of these categories, the architecture is a dedicated WebSocket service alongside your Symfony application—not WebSockets replacing Symfony. Your PHP backend remains the source of truth; the WebSocket layer is a transport-specific adapter. The mistake teams make is letting the WebSocket server become a second backend with its own business logic.
Getting This Into Production
The practical setup for a Symfony SaaS application using Mercure at /services/custom-software-development involves:
- Add
symfony/mercure-bundleand configure Hub credentials via environment variables. - Run the Mercure Hub as a sidecar container in your Docker Compose stack locally, and as a separate service (or use Mercure.rocks) in production.
- Publish updates from Symfony Messenger async handlers to keep HTTP response times clean.
- Issue per-user subscriber JWTs from your controllers; scope them to the topics that user is authorized to receive.
- Use Redis as the Hub transport if you run more than one Hub instance.
The full integration—from initial setup to a working live dashboard with private topics and presence—takes one to two days for a developer familiar with Symfony. That is the comparison point for WebSocket complexity: you are not saving time by using WebSockets, you are spending two to four times more of it.
If your team is standing up real-time features in a Symfony application and hitting friction, the question is usually not which protocol to use—it is whether the integration is structured correctly from the start. Feel free to reach out at hello@wolf-tech.io or visit wolf-tech.io to talk through your architecture before you build.

