Skip to content

SSR with NodeJS adapter leaking memory #21

@costalopes71

Description

@costalopes71

Hi folks, I'm not sure if this is a bug of @nanostores/vue or Astro.
First time writing an issue, not sure if I know the best practices, plz be kind.
There is an issue open in astro's repository probabily related to this.

Symptoms

Web BFF (Astro + Vue + Svelte + NodeJS adapter) was clearly leaking memory over time.

Evidence of the memory leak

  • in 20/30 minutes the services were restarting because health checks were failing;
  • heap memory usage only increased during the lifetime of the service, eventually reaching the allocated resource limit;
  • GC time only increased during the lifetime of the service;
Image PS: The memory dropping in the graph indicates the pod restart time.

Root causes of the problem

Problem 1 – Incorrect usage of subscribe and listen in nanostores on the server side (SSR)

Since we are running SSR in Node (via @astrojs/node), we should not use store.subscribe() or store.listen() directly on the server, because this creates listeners that are not “cleaned up” between requests. However, some parts of our code were doing this, which quickly accumulated many objects in memory and caused memory usage to continuously increase, never (or very rarely) going down or stabilizing.
To solve this problem we just moved this kind of code to lifecycle hooks like onMounted, because code inside then only run in the client. After fixing this problem, our services started leaking memory much more slowly, but it still leaked.

Image

Image above shows memory usage after fixing problem 1.

Problem 2 – Vue scopes not being cleaned up

The getCurrentScope() from the @nanostores/vue library was returning null in some situations, and onScopeDispose was not being executed, which prevented the cleanup of Nanostores listeners.

Evidence of Problem 2 – Vue scopes not being cleaned up

What we did to identify the problem was:

  • we profiled the application right after it started and took a memory snapshot;
  • we executed a high load of requests, then stopped, and took another memory snapshot;
  • when comparing the two snapshots, it became crystal clear that three types of constructors were growing significantly and none were being deleted: Dep, RefImpl, and function (all with exactly the same number of newly created objects and none removed).
Image

In the image, we are comparing the last snapshot with the first snapshot, with the result sorted by Size Delta. Notice that the first three rows are the ones that account for almost all the memory difference between the two snapshots, and also notice that new, deleted, and delta are identical for the three. Thousands allocated, none removed.

When following the retainers tree of one of these objects, we noticed that one of our stores was being referenced.

Image

Our store can be seen in the line that contains: buyFormContextStore in system / Context @211653

We identified the point in our code that uses this store and noticed that in the server-side code (since our component was client:idle) we were calling the useStore API from the @nanostores/vue library. When debugging this function, we realized that getCurrentScope was returning null, which prevented the cleanup of the created listeners (it is even possible to see the listeners array referencing the store in the image above), causing the listeners array to grow indefinitely.

Work arounds for problem 2

Work around 1

Use the client:only directive in components to force SSR to not happen (client:only directives cause the component code to be executed only on the client).

After applying this solution and running a new profile, the problem was solved and all objects were properly deleted from memory between snapshots.

Work around 2

We created a helper as a workaround, and in all places where we used the useStore API from the @nanostores/vue library, we replaced it with our helper, which basically checks whether we are in an SSR environment and, if so, only returns the state without creating subscriptions.

import { shallowRef, onScopeDispose, getCurrentScope } from 'vue';
import type { Store } from 'nanostores';

export function useStoreSafe<T>(store: Store<T>) {
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	const value = store.get?.() ?? (store as any).value;
	const state = shallowRef<T>(value);

	if (import.meta.env.SSR) {
		return state;
	}

	const unsubscribe = store.subscribe((v) => (state.value = v));

	const scope = getCurrentScope();
	if (scope) {
		onScopeDispose(unsubscribe);
	}

	return state;
}

How to reproduce the problem

In this repository I built a minimal project with instructions in the README to reproduce the issue.
PS: very easy and very fun to reproduce, give it a try

Our result

After the work around applied, memory usage stabilized between 70 and 90 megabytes.

Image Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions