object_pool 🎯
A high-performance, generic object pool for Dart and Flutter that eliminates Garbage Collector (GC) pressure by reusing pre-allocated objects instead of creating and destroying them on every request.
The Problem
Dart's GC must pause your app to collect short-lived objects. When you create thousands of objects per second — common in games, particle systems, and real-time data processing — GC pauses cause visible stuttering (jank) in Flutter UIs.
// ❌ This causes GC pressure — 60,000 heap allocations/sec at 60fps
void onFrame() {
for (var i = 0; i < 1000; i++) {
final p = Particle(); // ← heap allocation
p.init(x: i.toDouble(), y: 0);
// p goes out of scope → GC must collect it → frame drops
}
}
The Solution
// ✅ Zero GC pressure — zero allocations after warmup
final pool = ObjectPool<Particle>(
factory: () => Particle(),
initialSize: 1000, // pre-allocate once
);
void onFrame() {
for (var i = 0; i < 1000; i++) {
final p = pool.acquire()!; // O(1), no allocation
p.init(x: i.toDouble(), y: 0);
// ... use p ...
pool.release(p); // O(1), no deallocation/GC
}
}
Installation
Add to your pubspec.yaml:
dependencies:
object_pool: ^1.0.0
Then run:
dart pub get
# or
flutter pub get
Quick Start
import 'package:object_pool/object_pool.dart';
// 1. Create the pool
final pool = ObjectPool<MyObject>(
factory: () => MyObject(),
initialSize: 50, // pre-allocate 50 objects
maxSize: 200, // grow up to 200 if needed
);
// 2a. Manual acquire/release
final obj = pool.acquire();
if (obj != null) {
obj.doWork();
pool.release(obj); // always release!
}
// 2b. Scoped use() — RECOMMENDED (auto-releases even on exception)
pool.use((obj) {
obj.doWork();
}); // obj automatically released here
// 2c. Async scoped
await pool.useAsync((obj) async {
await obj.fetchData();
});
// 3. Monitor performance
print(pool.metrics); // utilization, hitRate, acquires/sec, etc.
// 4. Dispose when done
pool.dispose();
Implementing Poolable (Optional but Recommended)
Implement Poolable to hook into the pool's acquire/release lifecycle:
class Particle implements Poolable {
double x = 0, y = 0;
double velocityX = 0, velocityY = 0;
double alpha = 1.0;
bool active = false;
void init({required double x, required double y}) {
this.x = x; this.y = y;
}
@override
void onAcquire() {
active = true; // called just before object is returned to caller
}
@override
void onRelease() {
// called when returned to pool — RESET ALL STATE here
x = 0; y = 0; velocityX = 0; velocityY = 0;
alpha = 1.0; active = false;
}
@override
bool get isValid => true; // return false for broken/expired objects
}
Overflow Strategies
What happens when all objects are in use? Choose your strategy:
// Default: grow the pool (safe for most cases)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: ExpandStrategy(maxSize: 500, growthIncrement: 10),
);
// Drop the request — return null (best for particles, sound FX)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: const DiscardStrategy(),
);
// Reclaim the oldest in-use object (best for sound channels)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: const ReplaceStrategy(),
);
| Strategy | Exhaustion Behavior | acquire() Returns |
Best For |
|---|---|---|---|
ExpandStrategy |
Creates new objects | Non-null (new object) | General use ✅ |
DiscardStrategy |
Does nothing | null |
Particles, audio FX |
ReplaceStrategy |
Reclaims oldest | Non-null (reclaimed) | Sound channels, VFX |
Performance Tuning
final m = pool.metrics;
// Utilization: fraction of pool currently in use (0.0–1.0)
// > 0.85 sustained → increase initialSize or maxSize
print('Utilization: ${(m.utilizationRate * 100).toStringAsFixed(1)}%');
// Hit rate: fraction of acquires served from existing pool
// < 0.95 → increase initialSize to reduce expansions
print('Hit rate: ${(m.hitRate * 100).toStringAsFixed(1)}%');
// Acquires per second — helps size the pool for peak load
print('Acq/sec: ${m.acquiresPerSecond.toStringAsFixed(0)}');
// Exhaustion count — how often requests were dropped (DiscardStrategy)
print('Drops: ${m.exhaustionCount}');
// Peak active — highest simultaneous usage; use as initialSize guide
print('Peak active: ${m.peakActiveCount}');
API Reference
ObjectPool<T> Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
factory |
T Function() |
required | Creates new instances of T |
initialSize |
int |
10 |
Objects to pre-allocate at startup |
maxSize |
int? |
null |
Hard cap (used by default ExpandStrategy) |
strategy |
PoolOverflowStrategy<T>? |
ExpandStrategy |
Exhaustion behavior |
validateOnAcquire |
bool |
false |
Check Poolable.isValid before returning |
resetOnRelease |
bool |
true |
Call Poolable.onRelease automatically |
Methods
| Method | Returns | Description |
|---|---|---|
acquire() |
T? |
Get an object; null if exhausted + strategy returns null |
acquireOrThrow() |
T |
Get an object; throws PoolExhaustedException on failure |
release(T) |
void |
Return an object to the pool |
releaseIfNotNull(T?) |
void |
Release if not null (convenience) |
use<R>(R Function(T)) |
R? |
Scoped acquire+release (sync) |
useAsync<R>(Future<R> Function(T)) |
Future<R?> |
Scoped acquire+release (async) |
prewarm(int) |
void |
Ensure pool has at least N objects ready |
drain() |
void |
Remove all idle objects (free memory) |
dispose() |
void |
Destroy the pool and all objects |
Properties
| Property | Type | Description |
|---|---|---|
metrics |
PoolMetrics |
Performance statistics snapshot |
size |
int |
Total objects (in-use + available) |
activeCount |
int |
Objects currently checked out |
availableCount |
int |
Objects ready for acquisition |
hasAvailable |
bool |
Whether at least one object is free |
Common Use Cases
🎮 Game — Particle System
class Particle implements Poolable { /* ... */ }
final particlePool = ObjectPool<Particle>(
factory: Particle.new,
initialSize: 500,
maxSize: 1000,
strategy: const DiscardStrategy(), // drop excess particles gracefully
);
// On explosion:
void spawnExplosion(double x, double y) {
for (var i = 0; i < 50; i++) {
final p = particlePool.acquire();
if (p == null) return; // pool full — skip gracefully
p.init(x: x, y: y, angle: Random().nextDouble() * 6.28);
activeParticles.add(p);
}
}
// Each frame:
void update() {
activeParticles.removeWhere((p) {
p.update();
if (!p.alive) {
particlePool.release(p); // O(1) — no GC
return true;
}
return false;
});
}
🌐 Network — Connection Pool
class HttpConnection implements Poolable {
bool _valid = true;
Future<Response> get(String url) async { /* ... */ }
@override void onAcquire() {}
@override void onRelease() {} // keep TCP connection alive
@override bool get isValid => _valid;
}
final httpPool = ObjectPool<HttpConnection>(
factory: HttpConnection.new,
initialSize: 10,
maxSize: 50,
validateOnAcquire: true, // auto-replace broken connections
);
Future<Response> fetch(String url) async {
return await httpPool.useAsync((conn) => conn.get(url))
?? Response.error(503);
}
🎵 Audio — Sound Channel Pool
final soundPool = ObjectPool<SoundChannel>(
factory: SoundChannel.new,
initialSize: 32,
maxSize: 32,
strategy: const ReplaceStrategy(), // new sounds replace oldest
);
void playSfx(String asset) {
soundPool.use((channel) => channel.play(asset));
}
🏗️ Prewarm Before Peak Load
// During Flutter app splash screen / level loading:
Future<void> loadResources() async {
particlePool.prewarm(1000); // allocate 1000 particles NOW, not during gameplay
await Future<void>.delayed(const Duration(seconds: 2)); // loading animation
}
Common Mistakes to Avoid
❌ Using an object after releasing it
final obj = pool.acquire()!;
pool.release(obj);
obj.x = 10; // DANGER: obj may now belong to another caller!
// ✅ Use scoped pattern — impossible to use after release
pool.use((obj) {
obj.x = 10; // safe — can't escape this scope
});
❌ Forgetting to release (pool leak)
Future<void> bad() async {
final obj = pool.acquire()!;
await riskyOperation(); // throws → release never called → leak!
pool.release(obj);
}
// ✅ useAsync guarantees release even on exception
Future<void> good() async {
await pool.useAsync((obj) => riskyOperation());
}
❌ Pool too small for peak load
// ❌ Causes constant expansions → defeats the purpose
final pool = ObjectPool<Particle>(factory: Particle.new, initialSize: 5);
// ✅ Profile peak usage with metrics.peakActiveCount, then size accordingly
final pool = ObjectPool<Particle>(
factory: Particle.new,
initialSize: 200, // observed peak × 1.25
);
Performance Expectations
On a typical modern device with HeavyObject (~200 bytes):
| Scenario | Naive (alloc/GC) | Object Pool | Speedup |
|---|---|---|---|
| Single acquire/release | ~600 ns | ~60 ns | ~10× |
| 1000/frame at 60fps | ~36 ms/s GC | ~0 ms GC | ∞× GC events |
| Peak memory pressure | High (fragmented) | Low (fixed block) | Significant |
The biggest win is not raw speed — it's eliminating GC pauses entirely. Each GC pause that exceeds 16ms drops a frame and causes visible jank. Object pools make GC pauses from short-lived objects structurally impossible.
Internal Architecture
ObjectPool<T>
│
├── _entries: List<PoolEntry<T>> ← all objects, in-use + available
├── _entryMap: HashMap.identity() ← O(1) object → entry lookup
└── _freeListHead: PoolEntry<T>? ← head of embedded free list
│
PoolEntry<T> ▼
├── object: T [Entry0] → [Entry3] → [Entry1] → null
├── inUse: bool free free free
├── nextFree: PoolEntry<T>?
├── lastAcquiredAt: DateTime?
└── reuseCount: int
The embedded free list is the key insight: available entries form a
linked list that threads through the entries themselves via nextFree.
No separate data structure is needed. Both acquire() (pop head) and
release() (push head) are O(1).
Changelog
See CHANGELOG.md.
License
MIT — See LICENSE.
Contributing
Issues and PRs welcome at the GitHub repository.
Please run dart test and dart analyze before submitting a pull request.
Libraries
- object_pool
- A high-performance, generic object pool for Dart and Flutter.