Skip to content

Fix WebSocket channel leak on React component unmount (fixes laravel/echo#475)#1

Open
JoshSalway wants to merge 1 commit into2.xfrom
fix/react-channel-cleanup-on-unmount
Open

Fix WebSocket channel leak on React component unmount (fixes laravel/echo#475)#1
JoshSalway wants to merge 1 commit into2.xfrom
fix/react-channel-cleanup-on-unmount

Conversation

@JoshSalway
Copy link
Owner

@JoshSalway JoshSalway commented Mar 18, 2026

Summary

Fixes laravel#475 — WebSocket channels are not properly closed on React component unmount, causing channel leaks, reference counter imbalance, and memory leaks in React SPAs.

Steps to Reproduce

  1. Set up a React SPA with React Router and @laravel/echo-react
  2. Create a component that uses useEcho() to subscribe to a channel
  3. Navigate away from the component (unmount it)
  4. Check the Reverb/Pusher server — the channel is still open (ChannelCreated fired but ChannelRemoved never fires)
  5. Navigate back — a new subscription is created, incrementing the internal counter to 2
  6. WebSocket connections accumulate with each navigation

Before (Bug)

Two issues in the useEcho hook:

  1. Double subscription: The subscription ref is initialized with resolveChannelSubscription() during render, then called again in useEffect, incrementing the counter twice (count = 2)
  2. Incomplete cleanup: The useEffect cleanup only stops listeners but doesn't call leave() to close the actual WebSocket connection. The counter never reaches 0, so leaveChannel() returns early.

After (Fix)

  1. Single subscription: Channel subscription is created only inside useEffect, not during render initialization
  2. Proper cleanup: The ref starts as null and is set inside useEffect. The teardown correctly releases the channel on unmount.

Root Cause

React 18+ Strict Mode calls render functions twice, which would create duplicate subscriptions. The original code tried to handle this with an initialized ref but the ref was initialized with a subscription during render, causing the double-subscribe. The cleanup path also didn't fully release the channel.

The Fix

  • Changed subscription ref initialization from resolveChannelSubscription() to null
  • Removed the initialized ref — no longer needed
  • Subscription creation happens only in useEffect
  • Added null guards on stopListening and listen for the nullable ref
  • Added null guards on notification channel access in useEchoNotification

Breaking Changes

None.

Files Changed

  • packages/react/src/hooks/use-echo.ts — Fixed subscription lifecycle (+20/-15)

Test Results

59 tests pass. Channels are properly created on mount and released on unmount. No double-subscription in React 18+ Strict Mode.

Move resolveChannelSubscription() call from render phase (useRef
initializer) into useEffect, so the channel reference count is only
incremented inside the effect lifecycle. Previously, React 18+ Strict
Mode (and other scenarios causing multiple render invocations) would
increment the count during render without a corresponding cleanup,
causing channels to accumulate and never close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket channels not closing on component unmount in React SPA

1 participant