Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

Best practices for heavy subscriptions react (native) app performance #1535

Closed
ilijaNL opened this issue Jan 9, 2018 · 23 comments
Closed

Best practices for heavy subscriptions react (native) app performance #1535

ilijaNL opened this issue Jan 9, 2018 · 23 comments

Comments

@ilijaNL
Copy link

ilijaNL commented Jan 9, 2018

Intended outcome:
Non laggy UI with many subscriptions aka apollo cache writes, lets say several incoming subscriptions (5+) per second. Quick summary of my proposal: create graphql HOC that only fetches data but not subscribes to it (like react-graphql) and then have another hoc which is similar to react-redux connect HOC, which subscribes only to the store thus giving more control to connect deeper components to data.

Actual outcome:
On react native, the performance is terrible. And because everything runs on one thread (UI + logic), the app stucks and becomes unusable. One of the problems with apollo client (and may be any deep nested data structure) is that a lot of re-renders need to happen when there is a data structure that higher than one level, so if there is a graphql hoc which has several levels, for example in my chat application:

const channelTypeQuery = type => gql`
  query GroupChannels  {
    chat{
      id
      channels:{
          ...ChannelFragment
      }
    }
  }
  ${channelFragment}
`;
export const channelFragment = gql`
  fragment ChannelFragment on Channel {
    id
    topic
    type
    channelEvents(limit: 1) @connection(key: "channelEvents") {
      ...ChannelEventFragment
    }
  }
  ${ChannelEventFragment}
`;

Now assume there is a subscription per channel (which is fine i guess in a chat application), The component that executes the channelTypeQuery has a list of channels with each channel item in the list the most recent channelEvent.

Assume some people are typing in several channels which triggers many subscriptions, thus the channelTypeQuery query will be updated and the graphql hoc will trigger re-render on the whole tree. Now you can do some shouldUpdateComponent checks or use pure components but still on a react native app this will cause large performance spikes. My idea: create graphql HOC that only fetches data but not subscribes (aka prefetching and filling the store) and then have another hoc which is similar to react-redux connect HOC, which is listening only to the apollo store thus giving more control to connect deeper components to data thus avoid many unnecessary re-rerenders and checks. And if data is not available just returns an error or null. The idea is similar to readFragment but I cannot find out how to have a sort of 'watchableFragment'.

I don't know if this is currently possible or someone has any good examples/use cases for this scenario.

I hope someone can make sense of this and give some feedback.

Ilija

Version

  • apollo-client@2.1.0
  • react-apollo@2.0.4
@ilijaNL
Copy link
Author

ilijaNL commented Jan 18, 2018

Anyone who comes across the same problem?

@Evanion
Copy link

Evanion commented Jan 22, 2018

Just before christmas I wrote a GraphQL server + react client for monitoring live casino tables .. it handled ~50 events/second.

In the beginning I had issues with the browser becoming unresponsive and crashing after a few minutes.

As you alude to, the main issue is the number of DOM updates. The JS thread can actually handle quite many socket events without problem.

My recommendation is that you debounce the UI updates.
I used react-debunce-render to debounce the state update up to 0.5 seconds:

import React from 'react';
import debounceRender from 'react-debounce-render';
import Table from '../../components/Table';

const Tables = ({ tables, loading, error }) => {
  if (loading) {
    return <div>Loading tables</div>;
  }

  if (error) {
    return <div>{error.message}</div>;
  }

  if (!tables) {
    return <div>no tables</div>;
  }
  let players = 0;
  tables.forEach(table => (players = players + table.players));
  return (
    <div>
      <p className="App-intro">
        {players} players online, with {tables.filter(table => table.open).length} tables
      </p>
      <ul className="table-grid">
        {tables &&
          tables
            .filter(table => table.open)
            .sort((a, b) => {
              if (a.name < b.name) return -1;
              return 1;
            })
            .sort((a, b) => a.players - b.players)
            .reverse()
            .map(table => (
              <li key={table.tableId}>
                <Table table={table} />
              </li>
            ))}
      </ul>
    </div>
  );
};

// .filter(table => table.gameType === 'Roulette')
export default debounceRender(Tables, 100, { leading: false, maxWait: 500, trailing: true });

@ilijaNL
Copy link
Author

ilijaNL commented Jan 22, 2018

This won't help however in cases with the graphql subscriptions that apollo clients provides, since even if you dont render anything, it still has the same impact. This is duo the fact that the subscriptions are somehow stored/diffed against the current state of the apollo store. On mobile this causes a long ui freezes. However I managed to solve this issue by creating own redux store and use the link interface (so no apollo client in between) to subscribe to my subscriptions. I also use a debounce lodash function to batch the incoming subscriptions to a larger array which is dispatched all at once.

@clayne11
Copy link
Contributor

I think that part of this is related to a complete lack of query invalidation at the moment in apollo-client. See apollographql/apollo-client#2895.

@ilijaNL
Copy link
Author

ilijaNL commented Feb 15, 2018

I have read your issue but in my case the problem is not because the views get re-rendered on new subscription. In my case, the store gets really slow receiving and processing new data when it gets really big and since all the subs are stored in the store (why?) the store can explode in matter of seconds (eg chat app). At the end I just ended up using a normalized redux store with just using the ws link interface to subscribe and process the data.

@7ynk3r
Copy link

7ynk3r commented Feb 16, 2018

@Rusfighter i'm having the same problem. i think it has to do with caching. i tried both InMemoryCache and HermesCache with a similar result. my guess is that the normalization process requires a lot of processing.

can you expand more about your solution using redux & link?

@ilijaNL
Copy link
Author

ilijaNL commented Feb 17, 2018

let buffer = [];
      const writeToStore = debounce(() => { // debounce is from lodash
        handlePublishEvent(getCursor(), buffer, resubscribe); // resubscribe is a function which resubscribe if the order of events doesnt match (just for my case)
        buffer = [];
      }, 250);

execute(App.wsLink, { // execute is from apollo-link, wsLink is the apollo-link-ws
        query: SUBSCRIPTION_DOC,
        variables: { cursor },
      }).subscribe({
        next: (result) => {
          console.log('subscription result', result);
          if (result && result.data && isArray(result.data.chatEvents)) {
            buffer = buffer.concat(result.data.chatEvents); // I use buffer to cache several updates and write once to store
            writeToStore();
          }
        },
      });

The redux part is very different per application, just remember to use normalized structure which is best suited for performance

@JBustin
Copy link

JBustin commented Mar 22, 2018

Not only for native app.
We have a webapp with heavy subscriptions and we have the same problems.

Even if subscription doesn't render anything, sometimes, the interface freezes because a lot of data is pushed at the same time.

@ilijaNL
Copy link
Author

ilijaNL commented Mar 22, 2018

I think the only solution for now is using own data store and use wsLink interface to subscribe to graphql subscriptions. Perhaps use some debounce/throttle functions to avoid performance drops

@farzd
Copy link

farzd commented Nov 15, 2018

Sorry to drag this up. But @Rusfighter have you had any difficulties in keeping the subscription connection alive ? KeepAlive config option seems to ping but fails to hault the disconnect.

I’m using Apollo subscriptions and the client disconnects when idle after 6 or so minutes. It automatically reconnects when the app comes to the foreground.

Client also disconnects immediately once app goes to the background

@ilijaNL
Copy link
Author

ilijaNL commented Nov 15, 2018

@farzd I didn't used apollo subscriptions for a long time so I cannot help you but i remember there were some connection problems

@KamalAman
Copy link

KamalAman commented Nov 21, 2018

This is a very real performance issue for any type of high frequency realtime applications. A decent solution for this is to throttle and batch request to updateCurrentData query components, grouped by context.

import React from 'react';

import { Query } from 'react-apollo';

let contextMap = new Map();
let throttleTimeout

/**
 * Throttles all updates for a unique context, drops intermediate requests for the same context
 * Batches updates for all unique contexts together 
 */
const contextThrottle = function (context, callback) {

  if (!throttleTimeout) {
    throttleTimeout = setTimeout(() => {
      requestAnimationFrame(() => {
        for (let [context, callback] of contextMap) {
          callback.call(context);
        }

        throttleTimeout = undefined;
        contextMap.clear();
      })
    }, 200) 
  }

  contextMap.set(context, callback);
}

export default class ThrottledQuery extends Query {
   constructor(props, context) {
      super(props, context)

      let _updateCurrentData = this.updateCurrentData;
      this.updateCurrentData = () => {
         contextThrottle(this, _updateCurrentData)
      }
   }
}

It is possible there is a race condition if the component is unmounted before the throttle executes

It does not solve micro updating the store either

@lekoaf
Copy link

lekoaf commented Feb 12, 2019

This "crashes" an Electron app as well.

Right now we have a Query that uses subscribeToMore to refetch the Query on new data. We can get hundreds of updates in a matter of seconds and it totally freezes the UI.

Is there no built in throttling / debounce?

Before we implemented subscription we used pollingInterval on the Query every 1 second and that worked fine. We wanted to implement subscription to not do unneeded polling though.

@lekoaf
Copy link

lekoaf commented Feb 12, 2019

Solved it by debouncing updateQuery.
May not be pretty, but it seems to work.

@SebT
Copy link

SebT commented Feb 25, 2019

I did something similar to @Rusfighter's buffer solution and it works fine. But I think Apollo should provide a native way of throttling / debouncing subscription handlers.

Because with the buffer solution, I don't see how we can debounce updateQuery as it's the return value that is important. How did you debounce updateQuery in subscribeToMore @lekoaf ?

@booboothefool
Copy link

booboothefool commented May 16, 2019

Also having these problems with React Native + Apollo GraphQL Subscriptions. I am also building a chat application.

  1. Websocket connection randomly terminates. Tried all sorts of keepAlive but it is very intermittent and hard to reproduce, though it seems to work better on simulator than real phone. Sometimes my messages never come through when clicking a new message notification from lock screen. It seems to always work the first few seconds the app is open, but then does not persist/reconnect later after some user inactivity. @farzd
  2. The main issue in the thread where it seems as if more chatrooms opened and more messages received ends up grinding the app to a halt rather quickly. I checked my render methods and nothing there seems out of the ordinary, so it is probably an issue to how the subscriptions are stored as @Rusfighter said.

I will give @Rusfighter solution of separate Redux store a try.

@farzd
Copy link

farzd commented May 17, 2019

@booboothefool so apparently the phones disconnect your subscription connection when idle/locked to conserve battery. Theres no way around this. I have resorted to using polling. You could potentially reconnect when app comes to foreground. Another alternative would be background tasks - this is available in expo, however its quite hacky and i had to resolve to background location and using that to fire my task [used this for heartbeat type prototcol / not subscriptions]

@booboothefool
Copy link

booboothefool commented May 19, 2019

@farzd Thank you for the insight. Yes, after some research phones disconnecting connections to save power seems to be the expected behavior (makes sense I guess). I am not a fan of polling due to the extra resource usage and sometimes less predictable behavior, so I was able to get a solution working for my use case by using the following:

This will restore the websocket connection after idle/lock/inactivity for new messages to come in:

    handleAppStateChange = nextAppState => {
        if (nextAppState === 'active') {
            // CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3
            if (wsClient.status !== wsClient.wsImpl.OPEN) {
                // To force a reconnect without losing current subscriptions, you need to pass both arguments to close. 
                // .close(false, false) is treated like the connection dropped and a reconnect attempt is made. 
                const isForced = false;
                const closedByUser = false;
                wsClient.close(isForced, closedByUser);
            }
        }
    }

    async componentDidMount() {
        AppState.addEventListener('change', this.handleAppStateChange);
    }

    componentWillUnmount() {
        AppState.removeEventListener('change', this.handleAppStateChange);
    }

Then this will refresh the queries that were missed during inactivity:

wsLink.subscriptionClient.on('reconnected', () => {
  refetchChatQueries();  // here I just stored the refetch() from the relevant queries and call them again
});

Now the websocket connection seems reliable, so this solves issue 2) for me. 👍

But I am still having the issue 1) when a few new messages are received from a subscription that the app lags out and freezes, so I will continue to try out the separate store method.

@booboothefool
Copy link

booboothefool commented May 19, 2019

I was just able to solve my issue 1) with the app grinding to a halt. Turns out in my case, I did not need a separate store.

I was doing (as seen in the old official documentation but they have since updated it with the proper way using wrapper and componentDidMount...):

render() {
  ...
  subscribeToMore(...)  // each new message causes re-render, so subscribeToMore is called each time with updateQuery
  return <FlatList /> 
}

I changed to:

class FlatListWithSubscription extends FlatList {
  componentDidMount() {
    this.props.subscribeToMore(...)
  }
}
render() {
  ...
  return <FlatListWithSubscription subscribeToMore={subscribeToMore} /> 
}

Lag and freezing = gone. 👍

@darrenfurr
Copy link

For what it's worth: Ideally the rate throttling & filtering would happen at the server level. For now, I just used lodash/debounce on the updateQuery to apply rate throttling on the client to prevent even the CPU on the tab from just going crazy.

@hwillson
Copy link
Member

We're moving towards only using GH issues in this repo for bugs. There doesn't really seem to be a bug here, so if anyone is interested in opening a feature request for anything in this issue thread, you can do that over in https://github.com/apollographql/apollo-feature-requests. Thanks!

@kaikun213
Copy link

kaikun213 commented Nov 18, 2019

I think this is still an issue and would consider this a bug as it has serious performance implications.

I receiving just a couple of messages e.g. 6 in 2-3 seconds each with a high payload (95613 length) and it causes the UI and whole browser to lag and the CPU to go crazy.

This should not happen, I tried both documented ways from Apollo, but neither worked.
Following the example of @Rusfighter , I can at least use the subscriptions. But going around the apollo client does e.g. require me to rewrite my queries and fragments to not have any @client annotations.

I think this should be addressed. If it is considered a feature, then I would consider a flag which allows to disable normalization or any cache interactions.

@amirmab
Copy link

amirmab commented Jan 9, 2020

Can someone clarify how many subscriptions you are talking about, we have a working solution that can handle 100 subscriptions per second as the display will refresh every N seconds.
But I am concern about production and 2,000 burst subscription per second for 10 seconds.
Does anybody deal with that much data?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests