docs

Managing Memory

Tune memory profiles, configure cache limits, and control garbage collection and memory reclamation.

On this page

You can control how much memory Ultralight keeps and when it lets go, whether your UI runs on a memory-constrained device or shares RAM with a game engine.

The library reclaims memory on its own as pages come and go— a handful of settings and two calls decide how eagerly.

Memory Profiles

Config::memory_profile sets how aggressively freed heap memory returns to the OS and balances allocation speed against memory footprint. The renderer reads this setting once when initialized, so configure it with the rest of your startup options in Config.

Profile Behavior
MemoryProfile::Balanced The default profile. Freed memory returns to the OS at a moderate pace, and allocation favors speed.
MemoryProfile::LowMemory Freed memory returns promptly, allocations are packed more densely, and garbage collection runs eagerly. Recommended for resource-constrained platforms and long-running pages— see Embedded and Device UI for tuning memory-limited devices.
MemoryProfile::Performance Freed memory is held for reuse and garbage collection runs less often, trading memory footprint for peak throughput on hosts with RAM to spare.

Caches and Heap Sizes

Cache Limits

Config::memory_cache_size caps the library's cache of decoded images, compiled JavaScript, and similar resources (in bytes, 256 MiB by default). Increasing this limit can improve performance for resource-heavy applications at the cost of higher memory usage.

Config::page_cache_size sets how many pages the back-forward cache keeps in memory (0 by default). If you increase this setting, you should raise memory_cache_size as well.

Heap Target

Config::max_heap_size sets a soft target for the total JavaScript heap in bytes. This is not a hard limit— the heap can grow beyond this value, but the garbage collector then treats the heap as under memory pressure and reclaims more aggressively to bring it back toward the target. A value of 0 (the default) derives the target from memory_profile. You can find the remaining heap configuration options in the Config reference.

Automatic Reclamation

Every recycle_delay seconds (0.5 by default) the library automatically performs a lightweight recycle of its internal caches from inside Renderer::Update() and Renderer::Render(). Letting the engine run this timer is the recommended approach. Setting it to 0 disables the automatic recycler entirely for applications that call Renderer::Recycle() on their own schedule.

Garbage collection runs automatically as well. When idle_gc_enabled is active (true by default), the renderer collects the JavaScript heap after the application has been idle with no user input for idle_gc_idle_time seconds (2.0 by default) and returns the freed memory to the OS.

While your application is actively rendering frames, collection still runs concurrently whenever the heap grows well past its previous collected size, triggering as eagerly as memory_profile specifies. You can set idle_gc_enabled to false if your application manages garbage collection directly.

🚧 Reclamation only happens while Update() is being pumped

An application that stops calling Renderer::Update() while idle receives no automatic reclamation— call Renderer::Recycle() or Renderer::PurgeMemory() explicitly in that case. AppCore pumps this loop automatically (see Updating and Rendering for integrating the renderer into your own frame update).

Reclaiming on Your Schedule

You normally don't need to call Renderer::Recycle() because the automatic timer handles it— call it yourself only when you want to control when reclamation happens.

RecycleMode::Lightweight (the default) performs the same work as the automatic recycler and is cheap enough to call every frame. RecycleMode::Full is a thorough, heavier reclaim that can take noticeably longer on a busy page, making it best suited for idle periods or transitions like a loading screen.

Renderer::PurgeMemory() attempts to release as much memory as possible. Never call it from a callback or from driver code.

C++
#include <Ultralight/Ultralight.h>

using namespace ultralight;

RefPtr<Renderer> renderer;

void BeginLevelTransition() {
  // Pseudo-code, put the loading screen up here.
  ShowLoadingScreen();

  ///
  /// Nobody's watching the UI, so do a thorough reclaim now instead
  /// of paying for it mid-gameplay.
  ///
  renderer->Recycle(RecycleMode::Full);
}

Measuring Memory Usage

Renderer::GetMemoryUsage() returns formatted memory statistics as a string you can display in a debug overlay, while Renderer::LogMemoryUsage() outputs the same information to your logger.

To inspect metrics over time, the Profiler periodically samples memory usage, VRAM consumption, and cache sizes— see Profiling and Tracing for tracking counters in your profiling tools.

Controlling the Collector

The Garbage Collection Listener

In the Pro edition and higher, you can observe and control the JavaScript heap's foreground collections— the collections that briefly pause the thread pumping Renderer::Update(). You install a GCListener using Renderer::set_gc_listener(), guarded by UL_HAS(GC_LISTENER) (see Editions and Feature Macros for edition flags). Your application retains ownership of the listener, which must outlive the Renderer or be cleared with nullptr before destruction, and all callbacks fire on the thread that pumps Renderer::Update().

OnRequestGC() fires when the engine decides the heap is worth collecting. By default, the collection runs immediately through Renderer::CollectNow() with the recommended code-drop mode. If you override OnRequestGC(), your application owns the collection— you must call CollectNow() with the mode you were given, either immediately or deferred to your run loop on the same thread to hide the pause. Deferring moves the pause rather than eliminating it, and the collection never runs if you do not call CollectNow().

OnPressureChanged() fires when JavaScript heap pressure rises past an internal threshold, even with automatic collection disabled. OnCollectComplete() fires whenever a foreground collection finishes, reporting how long the collection paused the thread and how many bytes were reclaimed.

Explicit Collection and Heap Status

Renderer::CollectNow() reclaims only the JavaScript heap while leaving rendering and GPU caches intact, so the next frame pays no re-warm cost. It runs synchronously by default, but can start a background collection instead. If you call it while JavaScript is currently executing on the stack, the collection begins once the script returns.

Renderer::gc_status() returns a snapshot of current heap pressure, elapsed time since the last collection, and live heap bytes that is cheap enough to poll every frame. When calling Renderer::Recycle(), you can supply a CodeDropMode alongside RecycleMode::Full to choose whether to discard Nothing, Linked JIT code, or All compiled code, balancing memory savings against re-warm cost (the code parameter has no effect with RecycleMode::Lightweight).

C++
#include <Ultralight/Ultralight.h>

using namespace ultralight;

RefPtr<Renderer> renderer;

#if UL_HAS(GC_LISTENER)
class MyGCListener : public GCListener {
 public:
  ///
  /// The engine wants to collect. Remember the request instead of
  /// running it now, so the pause lands on our next loading screen.
  ///
  void OnRequestGC(Renderer* caller, const GCInfo& info,
                   CodeDropMode mode) override {
    pending_renderer_ = caller;
    pending_mode_ = mode;
  }

  ///
  /// Call this from the loading screen, on the thread that pumps
  /// Renderer::Update().
  ///
  void RunDeferredGC() {
    if (pending_renderer_) {
      pending_renderer_->CollectNow(pending_mode_);
      pending_renderer_ = nullptr;
    }
  }

 private:
  Renderer* pending_renderer_ = nullptr;
  CodeDropMode pending_mode_ = CodeDropMode::Nothing;
};

MyGCListener gc_listener;

void AttachGCListener() {
  ///
  /// The Renderer doesn't take ownership, so our listener must outlive
  /// it (or be detached with nullptr before the Renderer is destroyed).
  ///
  renderer->set_gc_listener(&gc_listener);
}
#endif