Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Memory leak with Flutter Session Replay #142

Open
ioannisj opened this issue Dec 24, 2024 · 21 comments
Open

Memory leak with Flutter Session Replay #142

ioannisj opened this issue Dec 24, 2024 · 21 comments
Labels
bug Something isn't working Performance question Further information is requested Session Replay

Comments

@ioannisj
Copy link
Contributor

Description

from: https://posthog.com/questions/memory-leak-with-flutter-session-replay

@ioannisj ioannisj added the bug Something isn't working label Dec 24, 2024
@marandaneto
Copy link
Member

Asked questions on the link.

@JobiJoba
Copy link

JobiJoba commented Jan 7, 2025

I have the same error message but when I have a TextFormField on the page and SessionReplay activated.

Once the focus in on the TextFormField I get that error message every seconds

flutter: Error: Failed to capture screenshot.
flutter: Snapshot is the same as the last one.

My page also has SVG but removing them didn't change anything to the message - I don't know if it leads to a memory leak.

class OnboardingPage extends StatelessWidget {
  const OnboardingPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Form(
          child: Column(
            children: [
              TextFormField(
                 decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'Enter your character name',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter a name';
                  }
                  return null;
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

my config

final config =
        PostHogConfig('xxx')
          ..debug = kDebugMode
          ..captureApplicationLifecycleEvents = true
          ..host = 'https://us.i.posthog.com'
          ..sessionReplay = true
          ..sessionReplayConfig.maskAllTexts = false
          ..sessionReplayConfig.maskAllImages = false;

A simple example of how I use it in my project using 4.9.1.

@marandaneto
Copy link
Member

flutter: Snapshot is the same as the last one.

this is not an error btw, it's just logging.

@marandaneto
Copy link
Member

I have the same error message but when I have a TextFormField on the page and SessionReplay activated.

Once the focus in on the TextFormField I get that error message every seconds

flutter: Error: Failed to capture screenshot. flutter: Snapshot is the same as the last one.

My page also has SVG but removing them didn't change anything to the message - I don't know if it leads to a memory leak.

class OnboardingPage extends StatelessWidget {
  const OnboardingPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Form(
          child: Column(
            children: [
              TextFormField(
                 decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'Enter your character name',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter a name';
                  }
                  return null;
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

my config

final config =
        PostHogConfig('xxx')
          ..debug = kDebugMode
          ..captureApplicationLifecycleEvents = true
          ..host = 'https://us.i.posthog.com'
          ..sessionReplay = true
          ..sessionReplayConfig.maskAllTexts = false
          ..sessionReplayConfig.maskAllImages = false;

A simple example of how I use it in my project using 4.9.1.

@JobiJoba this looks like a different issue, mind creating a new issue and providing an MRE? a sample where I can just run and reproduce the issue since I can't reproduce it myself? Thanks.
I'll need to know which OS, version, Flutter version, etc.

@waskalien
Copy link

waskalien commented Feb 8, 2025

Hello everyone, apologies for the delay.
After further investigation, I’ve created a simple example that reliably reproduces the issue:

Main

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:provider/provider.dart';
import 'package:vize/core/config/analytics.dart';
import 'package:vize/core/config/env.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Env.load();
  await AnalyticsConfig.configurePostHog();

  runApp(
    ChangeNotifierProvider<AppViewModel>(
      create: (_) => AppViewModel(),
      child: const App(),
    ),
  );
}

class AppViewModel with ChangeNotifier {
  AppViewModel() {
    Timer.periodic(const Duration(milliseconds: 100), (_) {
      notifyListeners();
    });
  }
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(children: <Widget>[
      SvgPicture.asset('assets/icons/profile/0.svg'),
      Consumer<AppViewModel>(builder: (BuildContext context, AppViewModel viewModel, _) {
        return SizedBox.shrink();
      })
    ]);
  }
}

PostHog Configuration

import 'package:posthog_flutter/posthog_flutter.dart';
import 'package:vize/core/config/env.dart';

class AnalyticsConfig {
  AnalyticsConfig._();

  static Future<void> configurePostHog() async {
    final PostHogConfig config = PostHogConfig(Env.posthogApiKey)
      ..debug = false
      ..captureApplicationLifecycleEvents = false
      ..host = 'https://us.i.posthog.com'
      ..sessionReplay = true
      ..sessionReplayConfig.maskAllTexts = false
      ..sessionReplayConfig.maskAllImages = false
      ..sessionReplayConfig.throttleDelay = const Duration(milliseconds: 500);

    await Posthog().setup(config);
  }
}
  • When sessionReplayConfig.throttleDelay is lowered, the memory leak accelerates.
  • Increasing the Timer.periodic duration (e.g., 10 second instead of 100ms) eliminates the leak.
  • Disabling SessionReplay eliminates the leak.
  • The memory leak worsens as more SVGs are added to the page.

Tested on iOS 18.1

flutter version: 3.27.3
posthog_flutter version: 4.10.2 and olders
provider version: 6.1.2

Let me know if further details or adjustments to the MRE are needed, and thanks for your help in investigating this!

@marandaneto
Copy link
Member

@waskalien, which tools are you using to check for memory increase? standard memory view?
Can you provide an MRE repo where I can just run the project? It should include your full MRE with size, provider, flutter_svg, etc. Since my sample with a simple SVG isn't enough, thanks.

@marandaneto marandaneto added the question Further information is requested label Feb 19, 2025
@waskalien
Copy link

Hi @marandaneto, I believe the issue does not appear in Flutter DevTools, but it is clearly visible in Xcode’s Memory tab.
Here is the requested repository containing the MRE : https://github.com/waskalien/posthog-session-replay-memory-leak
Thanks for looking into this!

@marandaneto
Copy link
Member

@waskalien https://github.com/waskalien/posthog-session-replay-memory-leak/blob/68b7b0a7d3abb39634cd3c3d378a066a01491f54/lib/main.dart#L39-L41
is this something realistic? Session replay will only take a screenshot (or consume memory) if there are screen changes; apparently, you are forcing that.
If you remove that, do you still see an issue?
Is there a memory leak, or is it just consuming more memory? They are different things, though.

@waskalien
Copy link

@marandaneto Yes, this is realistic in our case, as our app includes various real-time animations and a specific animation that requires a periodic timer running at 60 FPS.

As I mentioned before, if we remove the periodic timer, the memory leak disappears, which is the core issue here. This is not just higher memory consumption, it’s a true memory leak.

To illustrate: even if we do nothing and just display a static SVG with no movement, the app’s memory usage keeps increasing indefinitely. Wouldn’t you consider that a memory leak?

@marandaneto
Copy link
Member

marandaneto commented Feb 25, 2025

To illustrate: even if we do nothing and just display a static SVG with no movement, the app’s memory usage keeps increasing indefinitely. Wouldn’t you consider that a memory leak?

Maybe something is not correctly disposed, indeed. (I will take a look)
Last question:
If you stop the timer after a few seconds (when the memory is high), does the memory go down automatically or does it stay high up?

@waskalien
Copy link

@marandaneto Thanks for looking into it!

After stopping the timer, some of the allocated memory is freed, but not all of it. For example, in a 50-second test, starting at 113 MB, memory increases to 125 MB. Once the timer is stopped, it drops to 119 MB, leaving a 6 MB residual increase that isn’t released.

During the timer’s execution, memory usage spikes even higher, and we can observe periodic small memory releases every few seconds. It forms a stair-step pattern, where some memory is freed, but not entirely. Let me know if you need more details

@marandaneto
Copy link
Member

Image

So using the memory view on Dart/Flutter, the memory is pretty stable.

@marandaneto
Copy link
Member

Improvements such as #165 could help here, improving performance overall and not only the leak, I mean.

@marandaneto
Copy link
Member

I found something here #166 but it didnt improve much so its not the cause

@marandaneto
Copy link
Member

So I finally narrowed down the issue to this line.

The problem isn't really memory leaking but a similar side effect.

We need an image so we have to call renderObject.toImage(...), and when we don't need it anymore, we call image.dispose() correctly, pretty much rather away.

The problem is that your screen is updating way too often, so we have to generate way too many images and the Dart/Flutter GC isn't running as often as it should, so a lot of images are in memory, because calling dispose is just a signal to be released, but only the Dart GC has the ability to free everything.

There's no way to call the GC manually sadly.

I think the best approach here is for you to increase your throttleDelay as much as possible or disable recordings for some specific screens that have constant animations. This would be possible with this feat request, but it's not done yet.

@waskalien
Copy link

I understand the GC behavior, but I still believe this is an issue.

I’ve pushed a commit adding a button to cancel the timer that is updating the UI. You can test this by:

  • Canceling the timer right after launch and checking memory.
  • Canceling it after several minutes and comparing.

Even with no UI updates, memory remains higher, indicating a leak. As mentioned, Flutter DevTools doesn’t detect it, but Xcode does.

Cancelling timer right after launch :
Image

Cancelling timer after 2min30sec :
Image

The longer you wait before canceling the timer, the more significant the memory leak will be

@marandaneto
Copy link
Member

@waskalien thanks for that.
I understand your point but I just don't have control over the GC, I cannot release the image after using it, we call dispose right away and that's the only thing we can do.
Do you have any other ideas?

@RCVZ
Copy link

RCVZ commented Mar 26, 2025

@marandaneto I think it's because the image is passed to _getImageBytes, a new handle is created. All handles need to be disposed of before GC can it up. Have you tried debugGetOpenHandleStackTraces?

Image

@marandaneto
Copy link
Member

nope, debugGetOpenHandleStackTraces is new to me, will check today

@marandaneto
Copy link
Member

The only thing I see is:

#0 new Image.. (dart:ui/painting.dart:1932:32)
#1 new Image.
(dart:ui/painting.dart:1934:6)
#2 _NativeScene.toImage.. (dart:ui/compositing.dart:72:26)

and once I call .dispose, debugGetOpenHandleStackTraces always returns empty

@RCVZ
Copy link

RCVZ commented Mar 31, 2025

Okay, then I was probably wrong. I thought that since the image was passed to _getImageBytes, a new handle would be created within _getImageBytes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working Performance question Further information is requested Session Replay
Projects
None yet
Development

No branches or pull requests

5 participants