ably / ably-js

Javascript, Node, Typescript, React, React Native client library SDK for Ably realtime messaging service
https://ably.com/download
Apache License 2.0
310 stars 55 forks source link

Too many Ably client connections on dev with Next JS #1779

Open grantsingleton opened 3 months ago

grantsingleton commented 3 months ago

I first initialize ably in my layout.

// ably-provider.tsx
'use client';

import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';

interface AblyProviderProps {
  children: React.ReactNode;
}

export default function AblyRealtimeProvider({ children }: AblyProviderProps) {
  const client = new Ably.Realtime({ key: process.env.NEXT_PUBLIC_ABLY_CLIENT_API_KEY });

  return <AblyProvider client={client} key='ably'>{children}</AblyProvider>;
}
// layout.tsx
import AuthProvider from 'components/auth/provider';
import ThemeProvider from 'components/theme/provider';
import Navigation from 'components/navigation';
import AblyRealtimeProvider from '@/components/ably/provider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {

  return (
    <AuthProvider>
      <AblyRealtimeProvider>
        <html lang="en">
            <ThemeProvider theme={theme}>
              <Navigation theme={theme} />
              <div className={styles.content}>
                {children}
              </div>
            </ThemeProvider>
        </html>
      </AblyRealtimeProvider>
    </AuthProvider>
  );
}

Then, I set up a channel provider and wrap a route with it.

// ably-channel.tsx
'use client';

import { ChannelProvider } from 'ably/react';

interface Props {
  channelName: string;
  children: React.ReactNode;
}
export default function Channel({ channelName, children }: Props) {
  return <ChannelProvider channelName={channelName}>{children}</ChannelProvider>;
}
import { Container } from '@radix-ui/themes';
import AiActions from 'components/ai/jobs/actions';
import Callouts from '@/components/callout/callouts';
import JobInstructions from './instructions';
import JobHeader from './header';
import { getJobData } from 'utils/jobs/get-job-data';
import RealtimeStatus from './realtime-status';
import AblyErrorReceiver from '@/components/ably/error-receiver';
import Channel from '@/components/ably/channel';

interface Props {
  params: { jobId: string; };
  searchParams: { [key: string]: string | string[] | undefined; };
}
export default async function JobPage({ params: { jobId }, searchParams }: Props) {
  const { job } = await getJobData({ jobId });

  const channelName = `realtime-status-${jobId}`;

  if (!job) {
    return null; // TODO: 404
  }

  return (
    <Container>
      <Channel channelName={channelName}>
        <JobHeader jobId={jobId} job={job} />
        <AblyErrorReceiver channelName={channelName} />
        <Callouts subscriptions={[jobId]} />
        <JobInstructions jobId={jobId} />
        <RealtimeStatus jobId={jobId} />
        <AiActions jobId={jobId} searchParams={searchParams} />
      </Channel>
    </Container>
  );
}

I then use useChannel in one of the children

'use client';

import { useChannel } from 'ably/react';
import useCallouts from '@/hooks/use-callouts';
import { ErrorMessage } from '@/utils/realtime/push-message';

interface Props {
  channelName: string;
}

export default function AblyErrorReceiver({ channelName }: Props) {
  const { setCallout } = useCallouts();

  useChannel(channelName, 'error', (message) => {
    setCallout(channelName, { type: 'error', message: (message.data as ErrorMessage)?.message });
  });

  return null;
}

Ably then proceeds to establish tons of connections. It's typically 20-40 but got over 250 one time and surpassed my free tier usage just on dev with one user (me).

It's currently unusable due to this. I've looked for what I might be doing wrong but can't figure it out.

Check out these stats of running this on dev. I asked about this on Discord with no response so decided to start an issue. I can't imagine anyone using NextJS is using Ably due to this issue. Screenshot 2024-06-01 at 6 41 21 AM

┆Issue is synchronized with this Jira Task by Unito

grantsingleton commented 3 months ago

I found the issue goes away when initialized like this:

'use client';

import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';
import { useEffect, useRef } from 'react';

interface AblyProviderProps {
  children: React.ReactNode;
}

export default function AblyRealtimeProvider({ children }: AblyProviderProps) {
  const clientRef = useRef<Ably.Realtime>();

  useEffect(() => {
    if (!clientRef.current) {
      const client = new Ably.Realtime({ key: process.env.NEXT_PUBLIC_ABLY_CLIENT_API_KEY });
      clientRef.current = client;
    }
  }, []);

  if (!clientRef.current) return <>{children}</>

  return <AblyProvider client={clientRef.current} key='ably'>{children}</AblyProvider>;
}

Of course, as should have been obvious. const client = new Ably.Realtime({ key: process.env.NEXT_PUBLIC_ABLY_CLIENT_API_KEY }); is establishing a connection each time the app re-renders. I found that even the Next JS / Ably example does it this way and must have the same issue with too many connections.

Anyone else using Ably in a Next JS app and knows of a better way to do this than I have done here with the ref?

The problem with this method is the downstream ChannelProviders now throw errors since the ably client does not immediately exist.

grantsingleton commented 3 months ago

Ive also tried this method of initializing outside of the function which seems to be what the react docs suggest:

'use client';

import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';
import { useEffect, useRef } from 'react';

interface AblyProviderProps {
  children: React.ReactNode;
}

const client = new Ably.Realtime({ key: process.env.NEXT_PUBLIC_ABLY_CLIENT_API_KEY });

export default function AblyRealtimeProvider({ children }: AblyProviderProps) {
  return <AblyProvider client={client} key='ably'>{children}</AblyProvider>;
}

but this still has the issue of creating a connection every time you refresh the page. The only solution i've found that maintains one connection is the ref method shown in the previous comment but it isnt usable since it throws errors that brief instant that the client does not exist. Ably is still currently unusable in next js due to this.

Question: can the Ably.Realtime not be a singleton? Why is it creating new connections?

ttypic commented 3 months ago

Hey @grantsingleton,

Thanks for bringing this up! Sorry to hear that you're having trouble with the Next.js setup. Improving the developer experience for Next.js is on our roadmap. We'll discuss this internally and come back with recommendations for your use case.

Meanwhile I looked at solution with useRef that you shared, and problem with ChannelProviders can be easily fixed. There is no need to initialize Ably client in useEffect. Ably client constructor is very lightweight and lazy. It's better to rewrite it this way:

export default function AblyRealtimeProvider({ children }: AblyProviderProps) {
  const clientRef = useRef<Ably.Realtime>();

  if (!clientRef.current) {
      const client = new Ably.Realtime({ key: process.env.NEXT_PUBLIC_ABLY_CLIENT_API_KEY });
      clientRef.current = client;
  }

  return <AblyProvider client={clientRef.current}>{children}</AblyProvider>;
}
grantsingleton commented 3 months ago

@ttypic thanks for looking into this. I tried your method and it still allows multiple connections. I got it over 10 on dev. Not sure how. Right now the only method able to hold it to 1 connection is putting it in a useEffect. I'll be trying other methods this week.

AkbarBakhshi commented 3 months ago

I have been using the method with dynamically importing the component and then using the api with token request as shown in the example below and my connection numbers are right.

https://github.com/ably-labs/NextJS-chat-app/tree/main/app

jnovak-SM2Dev commented 3 months ago

@ttypic Can the correct details be added to the react/next docs? It would nice if people didn't have to go through all the open issues to find a solution.

mikey555 commented 1 month ago

I'm having this issue too and can't figure out how to reduce the number of connections. Do we know if this is or what makes this a NextJS-specific issue?

VeskeR commented 1 month ago

Hi @mikey555 !

After further investigation, we found that the issue is often caused by the hot reloading mechanism (referred to as Fast Refresh in newer Next.js versions). During development, if you update a file containing a new Ably.Realtime call, the HMR mechanism in your development environment (React, Vite, Next.js apps all include HMR) refreshes the file, creating a new client and opening a new connection. However, the previous client is unaware of this replacement, so the connection count continues to rise with each file edit. This issue is not specific to Next.js but rather applies to any development environment with HMR. Thus, if you frequently edit a file with a new Ably.Realtime call during development, the connection count will keep increasing.

While we're working on providing a way for the library to detect when it has been reloaded and needs to close, the following workaround should help:

Simply place your new Ably.Realtime call in a separate file from those you're constantly updating. For example, extract it into a wrapper component that won't be edited frequently. If that's not feasible, you can manually refresh the page you're working on from time to time to close all open clients and connections.

TreNgheDiCode commented 3 weeks ago

Hi, I'm also having the same issue, what I did is put Ably in a React hook and using Singleton Pattern may decrease the connections flows.

'use client';

import * as Ably from "ably";

class AblyClient {
  private static instance: Ably.Realtime | null = null;

  private constructor() {} // Prevent direct instantiation

  public static getInstance(): Ably.Realtime | null {
    // Ensure this runs only on the client side
    if (typeof window === "undefined") {
      return null;
    }

    if (!AblyClient.instance) {
      AblyClient.instance = new Ably.Realtime({
        authUrl: "/api/ably",
        authMethod: "POST",
      });
    }
    return AblyClient.instance;
  }
}

export default AblyClient;

In the other components, just call:

 const client = AblyClient.getInstance(); 

image

Hi @mikey555 !

After further investigation, we found that the issue is often caused by the hot reloading mechanism (referred to as Fast Refresh in newer Next.js versions). During development, if you update a file containing a new Ably.Realtime call, the HMR mechanism in your development environment (React, Vite, Next.js apps all include HMR) refreshes the file, creating a new client and opening a new connection. However, the previous client is unaware of this replacement, so the connection count continues to rise with each file edit. This issue is not specific to Next.js but rather applies to any development environment with HMR. Thus, if you frequently edit a file with a new Ably.Realtime call during development, the connection count will keep increasing.

While we're working on providing a way for the library to detect when it has been reloaded and needs to close, the following workaround should help:

Simply place your new Ably.Realtime call in a separate file from those you're constantly updating. For example, extract it into a wrapper component that won't be edited frequently. If that's not feasible, you can manually refresh the page you're working on from time to time to close all open clients and connections.