Laravel Real-Time Notifications: Reverb vs Pusher - NextGenBeing
Back to discoveries

Laravel Real-Time Notifications: Reverb vs Pusher

Laravel Reverb vs Pusher for real-time notifications: a runnable private-channel pipeline, measured queue latency, and a break-even rule for cost.

AI Workflows Premium Content 11 min read
Bekzod Erkinov

Bekzod Erkinov

May 6, 2026 • 106 views
Size:
Height:
11 min read 4,085 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

Laravel Real-Time Notifications: Reverb vs Pusher

A Laravel notification that waits two seconds to reach the browser feels broken, and the cause is usually not Reverb or Pusher. In my runs (n = 10 requests per setup, one run each, so treat the numbers as indicative), moving the queue worker from its default poll to --sleep=0 cut median delivery from 1,805 ms to 302 ms. Separately, a stock Reverb process crashed at 1,013 connections until I installed ext-uv.

Decision rule. Under ~100 concurrent users, use Pusher's free Sandbox unless you already run a server. Between 100 and a few thousand, compare Pusher's $49–$99 plans with the ops hours you would spend (break-even table below). Beyond that, I have no measured Reverb capacity to back a recommendation, so load-test before you commit either way.

Below is a notification pipeline on Reverb: private channel auth, a queued broadcast, and an Echo client you can run from the post alone. Then comes the client config that points the same code at Pusher, and the cost math. Everything ran on 2026-10-07 in one Docker container with the client on a Windows host (so read timings as relative). I had no Pusher account, so the Pusher sections rely on its documentation and are untested.

Versions, as printed by composer show and npm ls on this run:

$ composer show laravel/framework | grep versions
versions : * v13.35.0
$ composer show laravel/reverb | grep versions
versions : * v1.12.0
$ php -v | head -1
PHP 8.5.11 (cli) (built: Sep 24 2026 19:10:01) (NTS)
$ npm ls
+-- [email protected]
`-- [email protected]

Doc links point at the 13.x pages. For a Pusher-only, production-flavoured take, see Building a Real-Time Notification System with Laravel and Pusher.

Install Reverb for Laravel real-time notifications

composer create-project laravel/laravel demo
cd demo
php artisan install:broadcasting --reverb --without-node --no-interaction
php artisan install:api --no-interaction   # Sanctum, used for the token auth below

This installs laravel/reverb, writes config/broadcasting.php, config/reverb.php and routes/channels.php, and fills .env with BROADCAST_CONNECTION=reverb and the REVERB_* keys. I used --without-node because I drove Echo from a plain Node script. In a normal app, drop that flag. Add the HasApiTokens trait to User and set QUEUE_CONNECTION=database (the install already created the jobs table).

Reverb needs ext-pcntl. My bare composer:2 image lacked it, so I ran docker-php-ext-install pcntl. Then:

php artisan reverb:start --debug     # Starting server on 0.0.0.0:8080

--debug prints every frame, so leave it off for load tests (docs).

Authorize the private channel

Notifications go out on App.Models.User.{id} (notifications docs). The installer already wrote the rule in routes/channels.php:

Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});

Echo calls POST /broadcasting/auth before subscribing. By default that route uses the web middleware (session cookie and CSRF), which is awkward for a mobile app or a script. I moved it behind Sanctum bearer tokens:

// bootstrap/app.php
->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',          // no `channels:` key any more
)
->withBroadcasting(
    __DIR__.'/../routes/channels.php',
    ['middleware' => ['auth:sanctum']],
)

For a classic session-based browser app, keep the channels: line instead. The callback is what matters either way.

Send the notification, and queue it correctly

class InvoicePaid extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $invoiceId, public int $amount) {}

    public function via(object $notifiable): array
    {
        return ['broadcast'];
    }

    public function toBroadcast(object $notifiable): BroadcastMessage
    {
        return (new BroadcastMessage([
            'invoice_id' => $this->invoiceId,
            'amount' => $this->amount,
        ]))->onQueue('broadcasts');
    }
}
// routes/api.php
Route::post('/invoices/{id}/pay', function (Request $request, int $id) {
    $request->user()->notify(new InvoicePaid($id, 4900));
    return ['queued' => true];
})->middleware('auth:sanctum');

That looks like one queued job. It is two, and the worker log from this run shows it:

App\Notifications\InvoicePaid ............ 160.34ms DONE
Illuminate\Notifications\Events\BroadcastNotificationCreated  112.81ms DONE

Gotcha: two hops. The notification runs on default, then queues BroadcastNotificationCreated onto broadcasts. A worker listening on only one of them silently stalls the other. Run php artisan queue:work --queue=broadcasts,default.

What the queue costs in latency

I timed "client sends POST /pay" to "Echo callback fires", n = 10 requests per setup, one run each. The time includes the HTTP round trip through Docker Desktop. The harness is below, so you can re-time your own setup:

// bench.mjs  -- node bench.mjs 10   (needs ids.json from the next section)
import { readFileSync } from 'node:fs';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

const { me, token } = JSON.parse(readFileSync('ids.json', 'utf8'));
const api = 'http://localhost:8000';
const N = Number(process.argv[2] ?? 10);
const echo = new Echo({
  broadcaster: 'reverb', Pusher, key: process.env.REVERB_APP_KEY,
  wsHost: 'localhost', wsPort: 8080, forceTLS: false, enabledTransports: ['ws'],
  authEndpoint: `${api}/broadcasting/auth`,
  auth: { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } },
});

let t0, done;
const times = [];
echo.

Unlock Premium Content

You've read 30% of this article

What's in the full article

  • Complete step-by-step implementation guide
  • Working code examples you can copy-paste
  • Advanced techniques and pro tips
  • Common mistakes to avoid
  • Real-world examples and metrics

Join 10,000+ developers who love our premium content

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.