Watching a boxlang-express server: metrics, tuning, and a lighter image
This site runs on one 1-vCPU DigitalOcean instance with no APM vendor — just a self-built page reading JVM metrics directly, and the habit of watching it. Covers which numbers actually matter (GC time ÷ uptime, not raw seconds), a real bug caught along the way (the JVM thought it had 8 cores, not 1), load-testing to find the real throughput ceiling, and trimming the Docker image 60% by dropping CommandBox entirely.
This site runs on a single DigitalOcean App Platform instance — 1 vCPU, 1GB RAM, no autoscaling, no dedicated ops tooling. No APM vendor, no Datadog agent, nothing like that. What it has instead is a management page I built that reads the JVM's own metrics directly, and the habit of actually looking at it. This is a walkthrough of what that page shows, which numbers on it are worth caring about, and three real things I changed on this server as a direct result of watching it.
What's actually on the page
ServerMonitor.bx pulls straight from the JVM's own MemoryMXBean/OperatingSystemMXBean/RuntimeMXBean/GarbageCollectorMXBean — no datasource involved, pure in-process JMX reads. Five sections: Memory (heap/non-heap used, committed, max), CPU & load (available processors, load average, process/system CPU load), Disk (container filesystem — explicitly not the host's), JVM (vendor/version/uptime/class loading), Garbage collection (per-collector counts and cumulative time), and Threads (live/peak/daemon/total started). No third-party library — just a small amount of code reading numbers the JVM already tracks.
The metrics that actually matter
A single snapshot rarely tells you much. What matters is trend and ratio:
-
Heap used, trend not snapshot — a sawtooth (rise, GC, drop) is healthy; a staircase that never drops is a leak.
-
GC time ÷ uptime — the best "is the JVM struggling" signal, since raw seconds are meaningless without knowing uptime:
GC time ÷ uptime Verdict < 1% Healthy 1–5% Keep an eye on it 5–10% Investigate > 10% Real problem This server runs
SerialGCdeliberately — single-threaded and stop-the-world, so a rising ratio here means requests visibly slowing down, not just background noise. -
Disk growth between deploys — this container has no persistent volume, wiped on every deploy. Growth without a deploy in between means something's writing to local disk that shouldn't be.
-
Load average sustained above ~1 on a pinned single vCPU — a spike is nothing, sustained means requests are queuing behind CPU.
A real bug this page caught
While idle, the page reported 8 available processors on an instance provisioned for 1 vCPU. DigitalOcean enforces that entitlement as CPU scheduling shares, not a hard quota the JVM's container-awareness can detect — so Runtime.availableProcessors() wasn't technically wrong, just not answering the question that mattered. SerialGC didn't care (already single-threaded), but anything sizing a thread pool off that number — a default ForkJoinPool, say — would size for 8 cores while genuinely having one. Fixed with a single flag:
-XX:ActiveProcessorCount=1
Deployed, and the page now correctly reads 1.
Finding the actual ceiling
Started cautious — 50 requests at concurrency 3 against the live production homepage, since real visitors are on this box and it has its own >60 req/min burst-detection heuristic. Clean (0 failures, ~230ms mean). To actually find the ceiling, I moved to a local instance running under production's exact JVM constraints and pushed to 300 concurrent connections:
| Concurrency | Failed | Req/sec | Mean latency | 95th % |
|---|---|---|---|---|
| 5 | 0 | 124/sec | 40ms | 64ms |
| 20 | 0 | 211/sec | 95ms | 142ms |
| 50 | 0 | 226/sec | 221ms | 353ms |
| 100 | 0 | 263/sec | 380ms | 570ms |
| 300 | 0 | 263/sec | 1,025ms | 1,765ms |
Zero failures at every level. Throughput plateaus around 210–260 req/sec between concurrency 20–100; pushing to 300 just added queuing delay, not more throughput — the real ceiling of a single vCPU. The good part is how it degrades: nothing errors or gets refused, it just queues, thanks to boxlang-express's TCP backlog (raised from the JDK default of 0 to 1024). For a low-traffic site, slowing down gracefully beats falling over.
Raising the heap cap
Originally capped at -Xmx400m, conservative against 1GB total RAM. With headroom confirmed and GC ratio comfortably low, raised to 640m for more room before a collection has to run.
Slimming the Docker image
The Dockerfile was built on ortussolutions/commandbox:boxlang purely to get CommandBox's box install for fetching ForgeBox modules — this app runs its own HttpServer via app.listen(), never CommandBox's server model, so CommandBox itself was dead weight at runtime. ortussolutions/boxlang:cli — a much smaller bare image — ships its own native installer, install-bx-module, that talks to ForgeBox directly with no CommandBox involved. Switching dropped the image from 1.95GB to ~768MB, verified with a real build and run against the actual database.
The takeaway
No heavy monitoring stack needed. A page that reads the JVM's own numbers, a habit of watching trends instead of snapshots, and occasionally pushing hard enough to see where it bends — enough to catch a real misconfiguration, confirm a real ceiling, and trim a third of a gigabyte off the deploy artifact.
Comments