All Articles

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:

  1. Heap used, trend not snapshot — a sawtooth (rise, GC, drop) is healthy; a staircase that never drops is a leak.

  2. GC time ÷ uptime — the best "is the JVM struggling" signal, since raw seconds are meaningless without knowing uptime:

    GC time ÷ uptimeVerdict
    < 1%Healthy
    1–5%Keep an eye on it
    5–10%Investigate
    > 10%Real problem

    This server runs SerialGC deliberately — single-threaded and stop-the-world, so a rising ratio here means requests visibly slowing down, not just background noise.

  3. 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.

  4. 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:

ConcurrencyFailedReq/secMean latency95th %
50124/sec40ms64ms
200211/sec95ms142ms
500226/sec221ms353ms
1000263/sec380ms570ms
3000263/sec1,025ms1,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.