Mirth Connect runs under the JVM, persists state to a relational database, talks to downstream systems over TCP, HTTPS, and JDBC, and executes user-supplied JavaScript on every message. Performance tuning is therefore not a single knob — it is a coordinated program covering the JVM, the operating system, the database, the network, and the channel configuration.
This guide covers what we change, in what order, and why. It assumes a production Mirth Connect 4.x deployment on Linux with PostgreSQL as the message store. Most of it applies equally to containerized deployments and to Mirth 3.x, with minor adjustments called out along the way.
If you prefer to hand this off, our Mirth Connect helpdesk team tunes production environments as part of our integration services — we've done this for more than 100 US healthcare organizations.
1. What Performance Tuning Actually Means
"Performance" in a Mirth Connect context is usually one of four things, and they trade off against each other:
- Throughput — messages/second sustained across all channels.
- Latency — median and p99 end-to-end time from source receipt to destination ACK.
- Resilience — ability to keep accepting messages when a downstream is slow or offline.
- Resource efficiency — CPU, memory, and database I/O consumed per million messages.
Before tuning anything, know which of these matters most in your environment. A busy ADT feed in a hospital typically optimizes for resilience and low-latency ACKs; a nightly batch of lab results from an upstream reference lab optimizes for throughput; a FHIR transformation workload often optimizes for CPU efficiency because transformer logic dominates. The right heap size, GC configuration, and queueing strategy are different for each.
For background on how Mirth processes messages internally — sources, transformers, destinations, queues — see our Mirth Connect complete guide and the installation guide.
2. Establish a Baseline First
Tuning without a baseline is guessing. Before you change anything, capture these metrics for at least a 24-hour window that includes your peak traffic period:
- Messages/second in and out of Mirth (total and per channel).
- JVM heap usage (min, max, average) and GC pause time (p50, p99, max).
- CPU usage on the Mirth host and on the DB host.
- Database transaction rate, lock waits, checkpoint/WAL activity.
- Disk I/O on the DB storage volume.
- Queue depth on each channel, per destination.
- End-to-end latency p50 / p95 / p99 for representative flows.
The simplest way to get JVM metrics is to enable JMX and scrape it with Prometheus and the jmx_exporter, or use VisualVM / Java Mission Control live. For Mirth internals, enable the built-in statistics export and graph per-channel throughput and queue depth.
Minimum JVM flags to enable GC logging on Java 11+ in mcserver.vmoptions:
-Xlog:gc*,gc+age=trace,gc+heap=info:file=/opt/mirthconnect/logs/gc.log:tags,uptime,time,level:filecount=10,filesize=50M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/mirthconnect/logs/heapdump.hprof
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9010
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=falseJMX without auth is fine behind a firewall on a trusted management network. Do not expose it to the public internet — for more on locking down Mirth itself, see our security and HIPAA checklist.
3. JVM Heap Sizing
Default Mirth installs ship with a conservative heap (often 256 MB – 1 GB). This is wrong for any production workload. Too small and you get frequent GC pauses, out-of-memory errors, and throttled throughput. Too large and you get long GC pauses and wasted memory that could serve the OS page cache or the database.
3.1 How to pick a heap size
Start by asking: what is the largest message I process, how many concurrent messages are in flight, and how deep can queues get during downstream outages?
Rules of thumb we use:
- Sum the size of the largest concurrent working set — say 10,000 in-flight messages × 50 KB average = 500 MB.
- Multiply by 4–6× to account for transformer scratch memory, queue buffers, and JVM metadata.
- Round up to a sensible even number (4, 8, 16 GB).
- Never exceed physical memory minus 1 GB for the OS — the moment the heap swaps to disk, Mirth is effectively down.
- Stay below ~32 GB per JVM to keep compressed ordinary object pointers (CompressedOops). Beyond that, pointer overhead grows and throughput per GB drops.
3.2 Set -Xms equal to -Xmx
Set the initial and maximum heap to the same value. This avoids the JVM growing the heap at runtime, which can pause the application and triggers latency spikes at inconvenient moments.
# In mcserver.vmoptions
-Xms8g
-Xmx8g3.3 Container memory limits
When running Mirth in a container, set the JVM heap to roughly 70% of the container memory limit and leave the remaining 30% for the OS, native memory, and JIT code cache. Use -XX:MaxRAMPercentage=70.0 if you prefer the JVM to size itself from the cgroup limit rather than hardcoding values.
4. Garbage Collection Tuning
After heap size, GC is the next biggest lever. A badly tuned GC on a 16 GB heap can easily produce 5-second stop-the-world pauses, which cause MLLP ACK timeouts and cascade into upstream senders dropping connections.
4.1 Use G1GC
G1 is the safe production default on Java 11+. It handles heaps from 2 GB to 64 GB well and gives you a pause-time goal you can tune against.
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=40
-XX:+ParallelRefProcEnabledMaxGCPauseMillis=200 is a soft target, not a guarantee. G1 will make allocation decisions aimed at keeping pauses under 200 ms but cannot guarantee it under memory pressure. If you observe p99 pauses above 500 ms in GC logs, either increase heap or investigate allocation rate.
4.2 When to consider ZGC or Shenandoah
On Java 17+ with heaps above 16 GB where pause-time sensitivity is the primary concern (e.g., tight SLA on ACK latency for a high-volume feed), ZGC can deliver sub-10ms pauses. Enable with:
-XX:+UseZGC
-XX:+ZGenerational # On JDK 21+ZGC trades some throughput for latency consistency. Validate under load before rolling out to production — some transformer workloads see a 10–15% throughput reduction compared to G1.
4.3 Read GC logs regularly
Set up alerts on the following conditions from parsed GC logs:
- Any GC pause above 2 seconds.
- Full GC events — with G1 these should be rare; several full GCs/day indicates an undersized heap.
- GC overhead above 10% of wall clock — the JVM is spending more than one-tenth of its time collecting.
- Heap after GC creeping upward over days — memory leak or insufficient heap.
For actual heap exhaustion scenarios and how to recover, see our companion post on the Mirth Connect Java heap space error.
5. Thread Pools and Destination Concurrency
Mirth processes messages using several thread pools: source readers, destination dispatchers, pruner threads, and background channel threads. Each has separate tuning.
5.1 Destination thread count
The single most impactful thread-level control is destination concurrency. In the channel's destination settings, look for "Thread count" or "Processing threads."
- Fast destinations (local file, in-memory, fast intranet services) — 1 thread is usually optimal; extra threads add overhead without benefit.
- Slow synchronous destinations (HTTPS POST to a third-party API with 500 ms latency, slow JDBC insert) — 4–16 threads let you overlap waits and improve throughput.
- MLLP sender destinations — usually 1 per destination, since MLLP is typically ordered. Increase only if the downstream explicitly supports concurrent connections.
5.2 Source thread count
For inbound MLLP listeners, the number of concurrent TCP connections the listener accepts is usually configured per-channel. A single source channel receiving from one sender needs 1 source connection. Receiving from many senders (e.g., an aggregator ingesting from a dozen upstream systems) benefits from higher connection limits.
HTTP and file-poller sources have their own concurrency controls. Poll intervals and response timeouts are often more impactful than thread count on these.
5.3 Global worker pool sizing
Mirth uses a shared pool for internal work queues. On a host with 8+ CPU cores, the defaults are usually fine. If you observe high thread counts in JVM thread dumps with most threads parked (meaning pool is bigger than needed), you can reduce; if dumps show lots of pending work, increase.
# Snapshot current thread state
jcmd <mirth-pid> Thread.print > threads.txt
grep -c 'java.lang.Thread.State: WAITING' threads.txt
grep -c 'java.lang.Thread.State: RUNNABLE' threads.txt5.4 Avoid runaway thread creation
Some transformer code spawns threads via new Thread() or uses unbounded executor services. This is almost always a mistake — Mirth already manages concurrency, and custom thread spawning interferes with GC and causes native memory pressure. Review transformer code for any Thread, Executors.newCached, or ad-hoc pool creation and remove it.
6. Channel Queueing Strategy
Mirth's channel-level queueing is one of its most valuable — and most misunderstood — features. It controls whether messages are persisted to the message store before dispatch, and whether the channel can continue accepting messages when a downstream is unavailable.
6.1 The three modes
- No queueing (synchronous) — message goes straight through transformer and destination. Fastest for the happy path; fails fast if the downstream is unavailable.
- Queue on failure — messages are dispatched directly; only failed messages are queued for retry. Good middle ground for most outbound channels.
- Always queue — every message is persisted to the queue first, then dispatched asynchronously. Maximum resilience; source can ACK upstream regardless of downstream state. Costs extra DB I/O per message.
6.2 When to pick each
Always queue is the right choice when:
- The upstream system cannot tolerate slow ACKs (e.g., a real-time lab instrument feed).
- The downstream is known to have scheduled maintenance windows or intermittent availability.
- You need ordered, at-least-once delivery guarantees with retry.
Queue on failure is right when:
- The downstream is usually healthy — queueing overhead on every message is avoidable.
- You want retry on transient failures (network blips, brief downstream outage) but don't need end-to-end decoupling.
No queueing is right when:
- Synchronous end-to-end confirmation is required (rare, but some ADT workflows).
- The downstream has its own reliable queueing and Mirth is just routing.
6.3 Queue retry intervals
The default retry interval in Mirth is aggressive — sub-second. For a destination that is truly down (e.g., a maintenance window), aggressive retries flood logs, saturate DB connections, and make recovery slower.
Tune retry count and interval per destination. We typically use an exponential backoff pattern: immediate retry, then 5 s, 30 s, 2 min, 10 min, with a maximum of 50 retries before moving to a dead-letter state. Messages in error state remain in the store for later manual replay; they are not dropped.
7. Database Indexing and Tuning
The Mirth message store database is the single biggest performance dependency outside the JVM. On heavy workloads, DB throughput is the throughput ceiling — if the database can't keep up, Mirth can't keep up.
7.1 Move off Derby
Mirth ships with an embedded Derby database for convenience. Derby is fine for development. It is not fine for production — it is single-writer, does not handle concurrent writers well, and has recovery issues under power loss. Migrate to PostgreSQL before your first production deployment. MySQL and SQL Server are supported but we default to PostgreSQL because it is the best-tested backend on the Mirth schema.
7.2 Critical indexes
The default Mirth schema includes indexes for the normal message lookup patterns, but two areas benefit from additional indexes under heavy load:
- Composite index on
(channel_id, received_date)for message store pruning queries. - Index on
message_idin the content tables if you issue frequent lookups by ID. - Index on error state columns if you have custom dashboards scanning for errored messages.
Before adding indexes, check PostgreSQL's own recommendations:
-- Find missing indexes (PostgreSQL)
SELECT schemaname, relname, seq_scan, idx_scan
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY seq_scan DESC
LIMIT 20;
-- Check slow queries
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;7.3 PostgreSQL server tuning
On a dedicated PostgreSQL host with 16 GB RAM serving a busy Mirth instance, starting parameters:
# postgresql.conf
shared_buffers = 4GB
effective_cache_size = 12GB
work_mem = 16MB
maintenance_work_mem = 1GB
wal_buffers = 16MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB
random_page_cost = 1.1 # SSD
effective_io_concurrency = 200 # SSD
max_connections = 200These are starting points, not final values. Use pgtune for your specific hardware, then adjust based on observed pg_stat_statements and pg_stat_bgwriter output.
7.4 Connection pool sizing
Mirth maintains a connection pool to the message store. Default values often need to grow under load — if you see "connection pool exhausted" warnings, increase pool size, but also increase max_connections on the database side proportionally. A ratio of pool size : max_connections of about 1 : 1.5 leaves room for admin connections and monitoring tools.
8. Message Store Pruning
Without pruning, the Mirth message store grows forever. A busy channel can add 100 GB/month to the database. Eventually performance degrades, indexes balloon, backup windows exceed the available maintenance window, and the database runs out of disk. Pruning is not optional for any production deployment.
8.1 Decide retention per channel
Different channels need different retention. A compliance-sensitive inbound feed may need 90 days of full content retention; a high-volume logging channel may need 7 days of metadata only. Configure retention at the channel level in Mirth's channel properties:
- Prune message content — the full raw message payload. Most disk impact; prune most aggressively.
- Prune message metadata — retention of message IDs, status, and processing timestamps. Keep longer for audit.
- Prune errored messages — usually a separate, longer retention so operators have time to review and replay.
8.2 Schedule pruning in low-traffic windows
The pruner holds database locks and consumes I/O. Run it when traffic is low — typically between 2:00 and 5:00 AM local time. Configure in Settings → Data Pruner:
- Enable the pruner.
- Set block size to 1000–5000 messages (smaller blocks hold locks less time).
- Schedule start time at 02:00 and stop time at 06:00 as a safety bound.
- Enable "Archive pruned messages" to an off-box location if you need longer-term retention for audit.
8.3 Vacuum and reindex after pruning
On PostgreSQL, aggressive pruning leaves behind dead tuples that autovacuum eventually cleans up — but autovacuum's default pace is conservative. On high-volume instances, run a manual VACUUM ANALYZE weekly and consider REINDEX CONCURRENTLY quarterly to keep index bloat under control.
9. MLLP Connection Reuse and Keepalives
Opening a new TCP connection (plus a TLS handshake for MLLPS) per message is expensive — often 50–200 ms of overhead that dwarfs the actual message processing time. Connection reuse is the lever that separates a well-tuned Mirth destination from a poorly-tuned one.
9.1 Keep MLLP connections alive
On MLLP sender destinations, enable the "Keep connection open" option (terminology varies slightly by Mirth version). This maintains a single TCP connection for all messages, reopening only on error. Combined with TCP keepalives at the OS level, this gives you near-zero per-message connection overhead.
# Enable TCP keepalive on the Mirth host
sudo sysctl -w net.ipv4.tcp_keepalive_time=60
sudo sysctl -w net.ipv4.tcp_keepalive_intvl=10
sudo sysctl -w net.ipv4.tcp_keepalive_probes=6Persist in /etc/sysctl.d/99-mirth.conf so settings survive reboot.
9.2 Stateful firewalls and idle timeouts
Many stateful firewalls silently drop idle TCP sessions after 30–60 minutes. A long-lived MLLP connection that sees no traffic during a quiet period will appear "broken" on the next send. Set TCP keepalive at the OS level to 60 seconds or less to ensure the connection stays tracked by the firewall. See our MLLP connection refused guide for the adjacent debugging steps.
9.3 HTTP destination connection pooling
For HTTP(S) destinations, connection pool size and keep-alive headers matter just as much. Mirth's HTTP sender uses Apache HttpClient under the hood. Configure the pool size to match expected concurrency, enable persistent connections on the downstream, and set a reasonable validation interval to avoid reusing half-dead connections.
10. Production Sizing Ladders
A reference for what Mirth Connect actually needs at different scales. These are our starting points — every deployment has quirks, but these are defensible defaults.
| Tier | Messages/Day | Heap | CPU | Database |
|---|---|---|---|---|
| Development / lab | < 50,000 | 2 GB | 2 vCPU | PostgreSQL or Derby |
| Small production | 50,000 – 500,000 | 4 GB | 4 vCPU | PostgreSQL 14+, 4 GB shared_buffers |
| Mid-size hospital / healthtech | 500,000 – 5,000,000 | 8 GB | 8 vCPU | PostgreSQL HA pair, SSD-backed |
| Enterprise / health system | 5,000,000 – 50,000,000 | 16 GB | 16 vCPU | PostgreSQL HA cluster, dedicated hardware |
| Multi-tenant platform | > 50,000,000 | 16–24 GB per node, 4+ nodes | 16+ vCPU per node | Partitioned PostgreSQL or sharded |
- ✓Development / lab: Single node, no HA, minimal queueing.
- ✓Small production: Single node OK; take scheduled backups and test restore.
- ✓Mid-size hospital / healthtech: Active/passive Mirth with shared DB; queueing enabled by default on outbound.
- ✓Enterprise / health system: Horizontally scaled Mirth nodes behind a load balancer; per-channel pruning; 24/7 monitoring.
- ✓Multi-tenant platform: Consider FHIR-first alternatives or a purpose-built integration platform for this tier.
11. Benchmarks and Expected Throughput
Numbers we have measured on well-tuned deployments, as rough guidance:
- Simple pass-through channel (MLLP in, MLLP out, no transformer): 800–1,500 messages/second per node on modest hardware.
- HL7 to FHIR transformation with a moderate-complexity transformer: 150–300 messages/second per node.
- HL7 to HL7 mapping with conditional logic and field manipulation: 400–800 messages/second per node.
- Database-backed enrichment (lookup per message against an external DB): 50–200 messages/second per node, usually limited by the lookup DB.
These assume PostgreSQL on SSD, 8 vCPU, 8 GB heap, G1GC. Your numbers will differ — treat this as an order-of-magnitude sanity check, not a target.
If you need a point of comparison against other engines or are considering a platform change, see our best HL7 integration engines 2026 comparison and the Mirth Connect alternatives 2026 overview.
12. Bottleneck Profiling Techniques
When throughput plateaus despite tuning, profile before guessing. Three techniques we use, in order from cheapest to most invasive.
12.1 JMX and live metrics
Hook up VisualVM or Java Mission Control. Look for: heap growth trend, GC activity, thread count, CPU hotspots. JMC's flight recorder (free on OpenJDK) gives a detailed per-method profile with low overhead.
12.2 Async-profiler for CPU hotspots
# Attach async-profiler to the running Mirth process
./profiler.sh -d 60 -f mirth-flame.html <mirth-pid>
# Interpret the flame graph — wide bars are where CPU is spentCommon hotspots: XML parsing in transformers, regex evaluation, Rhino script compilation, database serialization. Each has a targeted remediation (precompile scripts, avoid regex in hot paths, batch DB writes, etc.).
12.3 Database query profiling
-- Find the slowest queries the Mirth DB is running
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_%'
ORDER BY total_exec_time DESC
LIMIT 20;If a transformer query dominates, that's the target. Move it to a prepared statement, cache the result in the channel map if it's repeated, or denormalize the lookup table.
12.4 Thread dumps at the moment of slowdown
When throughput drops suddenly, grab three thread dumps 5 seconds apart:
for i in 1 2 3; do
jcmd <mirth-pid> Thread.print > thread-dump-$i.txt
sleep 5
doneCompare: threads stuck at the same stack frame across all three are blocked on something external. Usually a database lock, a slow downstream, or an I/O wait.
13. Frequently Asked Questions
How much heap does Mirth Connect need in production?
A baseline production Mirth Connect instance should start at 4 GB of heap for low-volume environments (under 100,000 messages/day across all channels) and scale to 8–16 GB for high-volume enterprise deployments. Heap above 32 GB loses compressed-oops efficiency on the JVM and is usually better addressed by horizontal scaling.
Which garbage collector should I use for Mirth Connect?
For Java 11+ deployments, G1GC is the safe default and the one we recommend for production. ZGC and Shenandoah can provide lower pause times on heaps above 16 GB but require careful validation. Never run Mirth under the old Parallel or CMS collectors on heaps larger than 4 GB — long stop-the-world pauses will cause MLLP connection drops.
How many destination threads should I configure per channel?
Start with 1 thread per destination and only increase when profiling shows destinations are the bottleneck. Destinations that talk to slow downstream systems (synchronous HTTPS, slow databases) benefit from 4–16 parallel threads; destinations that write to local files or fast internal services rarely benefit from more than 2. Threads beyond saturation consume memory without adding throughput.
Should I enable channel-level queueing?
Yes for outbound channels where the downstream destination can be temporarily unavailable — queueing lets Mirth accept messages from the source, ACK them immediately, and retry delivery asynchronously. No for channels where synchronous end-to-end confirmation is required (e.g., certain ADT flows where the source must know the destination received the message before proceeding).
What database should I use for the Mirth message store?
PostgreSQL is the production default we recommend and what most NextGen / Oracle Health deployments standardize on. MySQL is supported but has historically had worse performance on the Mirth schema. Derby (the embedded default) is for development only — it cannot handle production volumes and will corrupt under pressure.
How often should I prune the Mirth message store?
Prune on a fixed retention policy, not opportunistically. For most production environments, a 14–30 day retention on message content and 90 days on metadata works well. Enable pruning at the channel level, schedule it during low-traffic windows, and monitor the pruner job for long-running transactions that block writes.
Why is my Mirth Connect instance slow but CPU and memory look fine?
Three common causes: (1) database contention — the message store has no index on a hot column, or the pruner is holding a long transaction. (2) destination saturation — one slow downstream is causing all threads in a pool to block. (3) Garbage collection pauses — a poorly tuned heap causes periodic long pauses that you won't see without GC logs. Enable GC logging and look there first.
How do I benchmark Mirth Connect throughput?
Use a load-generating tool (hl7TestTool, your own MLLP sender, or Apache JMeter with an MLLP plugin) to push a known message rate into a test channel with a minimal transformer and a file writer destination. Measure messages/second processed, JVM GC pause times, and database I/O. Re-run after each tuning change to confirm impact.
What message rate can a single Mirth Connect node handle?
With a well-tuned 8 GB heap, fast SSD-backed PostgreSQL, and modest transformer logic, a single node can comfortably handle 200–400 messages/second sustained across all channels (roughly 17–35 million messages/day). Heavier transformer logic, XSLT mapping, or slow downstreams reduce this proportionally.
When should I scale Mirth Connect horizontally instead of tuning?
Scale out when a single node is consistently above 70% CPU or heap after tuning, when required throughput exceeds 500 msg/sec, or when HA (high availability) is a hard requirement. Use a shared PostgreSQL cluster, put channels behind a load balancer that respects source-system affinity, and validate that your transformer logic is idempotent since multiple nodes may replay messages during failover.
Related Reading
- Mirth Connect: The Complete Guide
- Mirth Connect Installation Guide
- Common Mirth Connect Issues & Fixes
- Mirth Connect Database Configuration — Production Guide
- Mirth Connect Java Heap Space Error
- Mirth Connect MLLP Connection Refused
- Mirth Connect SSL/TLS Hardening
- Mirth Connect Security & HIPAA Checklist
- Mirth Connect Channel Not Starting
- HL7 Integration: The Complete Guide
- Healthcare Interoperability Guide
- Best HL7 Integration Engines 2026