I Replaced Pusher with Laravel Reverb and Cut Real-Time Costs by 90%

By amillionmonkeys
#Laravel#WebSockets#Backend Development

Laravel's new WebSocket server went into production on 3 client apps. Performance benchmarks, migration steps, and why I'm ditching third-party services.

When Laravel released Reverb in March 2024, I was skeptical. Another WebSocket server? I'd been running Pusher on three production apps for 18 months, and it worked fine. But when the monthly bill hit £340 for what was essentially message passing, I decided to investigate.

Three months later, I've migrated all three apps to Reverb. My real-time infrastructure costs dropped from £340/month to £32/month—a 90.5% reduction. Here's what I learned migrating production Laravel applications to self-hosted WebSockets.

What is Laravel Reverb?

Laravel Reverb is a first-party WebSocket server for Laravel applications, released as part of Laravel 11. It's a drop-in replacement for services like Pusher, Ably, or Socket.io that runs on your own infrastructure.

Unlike Laravel Echo Server (the previous community solution that's no longer maintained), Reverb is officially supported by the Laravel team and built on top of ReactPHP for high-performance async operations.

Key features:

  • Native Laravel integration (no configuration ceremony)
  • Horizontal scaling support via Redis
  • Built-in monitoring dashboard
  • Broadcasting to channels, private channels, and presence channels
  • Compatible with existing Laravel Echo client code

The pitch is simple: stop paying per-message fees to third-party services and run WebSockets on the server you're already paying for.

Why I Was Using Pusher (And What It Cost)

I'd chosen Pusher for the usual reasons: it works out of the box, has excellent documentation, and I didn't want to manage WebSocket infrastructure myself. For my three client apps, the use cases were straightforward:

  • App 1 (CRM): Live notifications when deals change status, team chat, presence indicators
  • App 2 (Project management): Real-time task updates, collaborative editing indicators, activity feeds
  • App 3 (Customer support): Live chat, ticket status updates, agent availability

Nothing exotic. But when you're broadcasting updates to 50-200 concurrent users across three apps, the costs add up fast.

My Pusher costs (monthly average):

  • Base plan: £29/month per app = £87
  • Message overage: ~£80-120/month per app
  • Total: £320-380/month (averaged to £340)

At that scale, Pusher isn't expensive because they're greedy—it's expensive because the pricing model charges per message and per connection. When you broadcast a task update to 30 team members, that's 30 messages. Send 100 notifications per day across a team, and you're burning through message quotas.

I was on the "Pro" tier for all three apps, giving me 10M messages/month. I hit 8-12M messages most months.

The Migration Decision

Laravel Reverb launched in March 2024. I watched it for two months, reading experience reports and waiting for production war stories. By May, enough teams had deployed it successfully that I felt comfortable testing.

My requirements:

  1. Zero downtime migration
  2. No changes to frontend code (I use Laravel Echo)
  3. Performance equal to or better than Pusher
  4. Support for all three apps on shared infrastructure
  5. Monitoring and debugging tools

The cost savings were attractive, but I wouldn't migrate if it meant degraded performance or operational headaches.

Migration on Three Production Apps

I migrated in phases over six weeks. Here's the process:

Phase 1: Development Environment (Week 1)

Installed Reverb on one app's staging environment:

composer require laravel/reverb
 
php artisan reverb:install

This publishes a config file (config/reverb.php) and adds environment variables to .env:

REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
 
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Updated config/broadcasting.php to use Reverb instead of Pusher:

'connections' => [
    'reverb' => [
        'driver' => 'reverb',
        'key' => env('REVERB_APP_KEY'),
        'secret' => env('REVERB_APP_SECRET'),
        'app_id' => env('REVERB_APP_ID'),
        'options' => [
            'host' => env('REVERB_HOST'),
            'port' => env('REVERB_PORT', 443),
            'scheme' => env('REVERB_SCHEME', 'https'),
            'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
        ],
    ],
],

Frontend changes were minimal. In resources/js/bootstrap.js, we switched from Pusher to Reverb:

// Before (Pusher)
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
 
window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    forceTLS: true
});
 
// After (Reverb)
import Echo from 'laravel-echo';
 
window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

No changes to event broadcasting code, channel definitions, or Echo listeners. This is the beauty of Laravel's abstraction—swap the driver, everything else stays the same.

Phase 2: Server Setup (Week 2)

I run all three apps on a single DigitalOcean droplet (4 vCPU, 8GB RAM, £48/month). Previously, I only used this for the Laravel apps. Now I'd add Reverb.

Started Reverb as a systemd service:

# /etc/systemd/system/reverb.service
[Unit]
Description=Laravel Reverb WebSocket Server
After=network.target
 
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/app1
ExecStart=/usr/bin/php /var/www/app1/artisan reverb:start --host=0.0.0.0 --port=8080
Restart=on-failure
RestartSec=5s
 
[Install]
WantedBy=multi-user.target

Enabled and started:

sudo systemctl enable reverb
sudo systemctl start reverb

Configured Nginx to proxy WebSocket connections:

# WebSocket proxy for Reverb
location /app {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
    proxy_pass http://127.0.0.1:8080;
}

SSL handled via Let's Encrypt (already configured). Reverb runs on HTTP internally, Nginx handles HTTPS termination.

Phase 3: Production Migration (Week 3-6)

Migrated one app per week. Process for each:

  1. Deploy Reverb config to production (keep Pusher active)
  2. Test Reverb in production with developer accounts
  3. Feature flag to switch 10% of users to Reverb
  4. Monitor for 48 hours (error rates, latency, connection stability)
  5. Increase to 50% of users
  6. Monitor for 48 hours
  7. Switch 100% of traffic to Reverb
  8. Monitor for one week before migrating next app

Issues I hit:

Issue 1: Connection drops under load

During peak hours (50+ concurrent users), WebSocket connections would drop after 30-60 seconds. The problem: Nginx's default proxy timeout.

Fix in Nginx config:

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

Issue 2: Scaling across multiple apps

I needed all three apps to share the same Reverb instance but keep their messages isolated. Reverb supports this via the REVERB_APP_ID config—each app gets a unique ID, and Reverb handles routing.

App 1: REVERB_APP_ID=app-crm App 2: REVERB_APP_ID=app-pm App 3: REVERB_APP_ID=app-support

Single Reverb instance, isolated message channels.

Issue 3: Presence channel state after restart

When I restarted Reverb, presence channels (who's online) lost their state. Users had to refresh to re-join. This is expected behavior—in-memory state is lost on restart.

Solution: I configured Reverb to use Redis for state persistence:

REVERB_SCALING_ENABLED=true
REVERB_REDIS_HOST=127.0.0.1
REVERB_REDIS_PORT=6379

Now presence state survives restarts.

Cost Savings: The Math

Before (Pusher):

  • 3 apps x £29/month base = £87
  • Message overages: ~£250/month average
  • Total: £337/month

After (Reverb):

  • Server: £0 (already paying £48/month for droplet, no increase needed)
  • Redis: £0 (already running for cache)
  • Monitoring (optional): £0 (using Reverb's built-in dashboard)
  • Total incremental cost: £0

But that's not quite fair—I should account for the server resources Reverb uses. Looking at my monitoring:

  • CPU: +0.3% average (negligible)
  • RAM: +180MB (on 8GB server)
  • Network: +2GB/month outbound

At current DigitalOcean pricing, that's worth about £4-5/month if allocated proportionally. Let's call it £8/month to be conservative and add £24/month for the peace of mind of actively monitoring it.

Actual new cost: £32/month Savings: £305/month (90.5%)

Over a year: £3,660 saved. That pays for 76 hours of development time, which far exceeds the migration effort (about 12 hours total).

Performance Benchmarks

I measured performance before and after migration using Reverb's built-in monitoring and my own metrics.

Message latency (p95):

  • Pusher: 45ms
  • Reverb: 28ms

Connection time (p95):

  • Pusher: 320ms
  • Reverb: 180ms

CPU usage (during peak broadcast to 50 users):

  • Pusher: N/A (handled externally)
  • Reverb: 12% spike on 4 vCPU droplet

Memory usage:

  • Idle: 180MB
  • Under load (200 concurrent connections): 340MB

Reverb is faster for me because the WebSocket server is on the same network as the Laravel app. Pusher requires an external HTTP request to their API, then they broadcast via WebSockets. Reverb cuts out the external hop.

Why I'm Ditching Third-Party Services

This migration crystallized a philosophy shift for me: default to self-hosted unless there's a compelling reason to outsource.

Reasons I'm moving away from third-party services:

  1. Cost predictability: Flat server cost vs. variable per-usage pricing
  2. Vendor independence: No lock-in, no surprise pricing changes
  3. Performance: Fewer network hops, lower latency
  4. Control: I can inspect logs, debug issues, customize behavior
  5. Simplicity: One less external service to monitor and integrate

This doesn't mean I self-host everything. I still use:

  • AWS S3 for file storage (hard to beat their pricing/reliability)
  • Postmark for transactional email (deliverability is specialized)
  • Fathom for analytics (privacy-focused, minimal setup)

But for things Laravel does natively—like WebSockets, queues, scheduling—I'm defaulting to self-hosted. Laravel Reverb, Horizon for queues, and cron for scheduling give me everything I need without external dependencies.

Migration Checklist

If you're considering migrating from Pusher to Reverb:

Before you start:

  • Verify you're on Laravel 11+ (Reverb requires Laravel 11)
  • Check you have Redis available (required for scaling/presence)
  • Ensure your server can handle WebSocket connections (CPU/RAM)
  • Review your current WebSocket usage patterns

Migration steps:

  • Install Reverb in development (composer require laravel/reverb)
  • Test all real-time features locally
  • Configure systemd service for production
  • Set up Nginx WebSocket proxy
  • Deploy to production with feature flag
  • Gradually roll out (10% → 50% → 100%)
  • Monitor for connection drops, latency, errors
  • Remove Pusher config after one week of stability

Post-migration:

  • Set up monitoring alerts (connection count, error rate)
  • Document Reverb restart procedure
  • Update deployment scripts to restart Reverb on deploy
  • Cancel Pusher subscription

Lessons Learned

What worked well:

  • Laravel Echo abstraction meant zero frontend changes
  • Gradual rollout caught issues before they affected all users
  • Self-hosting on existing infrastructure = zero incremental cost
  • Performance is better (lower latency, faster connections)

What I'd do differently:

  • Start with Redis scaling enabled from day one (I added it after the first restart)
  • Set up monitoring alerts before migration (I added them after week 2)
  • Test under load earlier (my local tests didn't simulate 50+ concurrent users)

Unexpected benefits:

  • Debugging is easier (logs are local, I can inspect Reverb's state)
  • Deploys are simpler (one fewer external service to coordinate with)
  • I understand the system better (no black box)

Conclusion

Laravel Reverb saved me £305/month (90.5%) on real-time infrastructure across three production apps. Migration took 12 hours of developer time over six weeks. Performance improved (28ms latency vs 45ms on Pusher). I have zero regrets.

If you're running Laravel 11+ and paying for Pusher, Ably, or similar services, Reverb is worth evaluating. The cost savings alone justify the migration effort, and the performance/control benefits are bonuses.

Key takeaways:

  • Self-hosted WebSockets can be simpler and cheaper than third-party services
  • Laravel's abstractions make migration nearly transparent
  • Plan for gradual rollout and monitor closely during migration
  • Budget 2-4 hours per app for migration effort

Planning a Laravel project with real-time features? I've now deployed Reverb on five apps and learned all the gotchas. If you'd like help architecting a cost-effective real-time system, get in touch—I'd be happy to share what I've learned.

Want to explore my approach to bespoke web development or see examples of production Laravel apps I've built? Check out my work or read more about my backend development expertise.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.