How-to: Safely Manage Middleware Resources (dispose, leaks, and lifecycle ownership)
Problem
Your middleware owns resources (subscriptions, timers, caches, external handles). You need to:
avoid memory leaks,
avoid cross-test or cross-module contamination,
ensure predictable cleanup,
keep middleware safe even when operations are cancelled (Interrupt) or fail.
Prerequisites
You understand the middleware lifecycle hooks:
canHandle(filter)before/after(per-operation hooks)dispose(cleanup)
You know when middleware is attached and removed in your app lifecycle.
You know that Interrupt/cancel happens in
beforeand can preventafterfrom being called.
Steps
1) Identify what your middleware “owns”
Make an explicit list of resources your middleware creates. Typical categories:
Long-lived (must be released in dispose)
RxJS subscriptions created by middleware itself
intervals/timeouts that repeat
event listeners (process, window, DOM, custom emitters)
external clients/handles created inside middleware
Per-operation temporary state (must not leak if after is skipped)
start timestamps for timing
correlation maps keyed by message id
short-lived buffers or counters
2) Decide who owns middleware lifetime
Pick one ownership model and stick to it:
App-owned (global)
middleware is attached once at startup
it lives as long as the bus lives
cleanup happens when the bus is disposed / app shuts down
Module-owned (scoped)
middleware is attached when a module/registrator activates
it is removed when the module/registrator deactivates
ideal for feature modules and isolation
Test-owned
middleware is attached during Arrange
removed/disposed during teardown
prevents test pollution
Rule of thumb:
If you need isolation, choose module-owned or test-owned.
If you need universal observability, choose app-owned.
3) Put all long-lived cleanup in dispose()
Anything that can outlive a single message processing must be cleaned in dispose.
Checklist for dispose:
unsubscribe from all subscriptions created by middleware
clear intervals/timeouts
remove event listeners
clear internal maps that could hold references
Best practice:
make
disposeidempotent (safe if called once; avoid throwing)
4) Design per-operation state so it cannot leak
Because after is not guaranteed (cancellation or failures), avoid designs that require after to always run.
Safe patterns:
No per-operation state: counters only, no maps.
Bounded map: Map keyed by message id with TTL/LRU cleanup.
Stage-limited state: only track timing for Execute stage (simpler, fewer cases).
Try-finally at the boundary (if you instrument outside middleware): when you own the call site.
Anti-pattern:
unbounded Map keyed by message id with cleanup only in
after.
5) Keep canHandle cheap and avoid accidental activation
Overly broad canHandle causes:
unnecessary computation,
more state allocations,
harder-to-debug leaks.
Do:
filter by stage first
then by message subset
6) Verify teardown is actually happening
Many leaks are not in middleware code, but in lifecycle wiring:
middleware attached but never removed
bus never disposed in tests
multiple bus instances created across reloads without teardown
Ensure you have a teardown strategy and that it runs:
app shutdown hook (if applicable)
module deactivation hook (if applicable)
test afterEach/afterAll cleanup
Verification (expected outcome)
Checklist
After a test/module teardown:
middleware
disposehas runmiddleware-owned subscriptions are closed
timers are cleared
internal maps are empty (or small and bounded)
During runtime:
memory does not grow linearly with number of processed messages
logging/metrics middleware overhead remains stable
Diagram: resource lifecycle ownership
@startuml title Resource ownership and disposal points
actor Owner participant "Postboy bus" as Bus participant "Middleware instance" as MW participant "Resources\n(subscriptions/timers/maps)" as R
Owner -> Bus: create bus Owner -> MW: create middleware Owner -> Bus: attach middleware Bus -> MW: middleware active
MW -> R: allocate long-lived resources (optional) note over MW: per-operation temp state\nmay be allocated in before
Owner -> Bus: remove middleware OR dispose bus Bus -> MW: dispose() MW -> R: release resources MW -> MW: clear internal state
@enduml
Troubleshooting
“My middleware leaks memory over time”
Most common causes:
Per-operation Map keyed by message id grows without bounds.
afteris skipped due to cancellations, so cleanup never happens.Timers/intervals are created and never cleared.
Subscriptions are created and never unsubscribed.
Fix steps:
Remove message id from metric labels and avoid storing large per-message state.
Add TTL/LRU cleanup to any map.
Move all long-lived cleanup to
dispose.Reduce scope via
canHandle.
“Tests affect each other”
Cause:
middleware attached globally and not removed/disposed between tests.
Fix steps:
Attach middleware per test (Arrange).
Ensure teardown removes middleware or disposes the bus.
Avoid global singleton bus in tests unless you have strict reset logic.
“dispose() is never called”
Cause:
middleware is never removed and the bus is never disposed.
Fix:
Add an explicit teardown step in your runtime (or in tests).
If your app architecture does not have teardown, prefer middleware with no long-lived resources.
Best practices (copy-safe rules)
Treat middleware as a long-lived object: anything it allocates must be releasable in
dispose.Do not rely on
afterfor cleanup (it’s not guaranteed on cancel).Use bounded state for per-operation tracking.
Keep filtering tight; do not run everywhere by default.
Prefer composition:
one middleware for logging
one for metrics
one for guards This makes lifecycle and ownership clearer.
Next steps
[Middleware lifecycle and contract (before/after/canHandle/dispose)][Interrupt/cancel semantics (why after() may not run)][How-to: measure duration and collect metrics][How-to: logging and tracing messages][Troubleshooting: diagnosing leaks and unexpected middleware behavior]