|
| 1 | +# SpringQueuePro — Memory Optimization (2026-01-13) |
| 2 | + |
| 3 | +This project is a massive memory hog and has been racking up my monthly Railway bill a ridiculous amount. This document is dedicated to a focused memory optimization pass performed on **2026-01-23** aimed at reducing JVM and application-level memory usage when running and hosting SpringQueuePro on any memory-billed PaaS platform. (As mentioned, I'm currently hosting this on Railway with their hobby plan which costs ~5CAD a month, which is the covered amount for your hosted project's memory usage. But this project, and even the base SpringQueue version, has been jumping my bill close to ~20CAD by month's end). |
| 4 | + |
| 5 | +## Memory Usage Breakdown |
| 6 | + |
| 7 | +It isn't *that* surprising that this project (**JVM + SpringBoot + metrics + Redis + GraphQL** being hosted **24/7**) has been so memory-hungry. The memory usage breakdown should look something like this: |
| 8 | +| Source | Memory Hog (Why) | |
| 9 | +| ----------------------------------- | ------------------------------------------------ | |
| 10 | +| **JVM default heap sizing** | JVM happily reserves hundreds of MB even at idle | |
| 11 | +| **Spring Boot auto-config** | Loads frequently while not in use | |
| 12 | +| **ExecutorServices + schedulers** | Threads = stack memory + queues | |
| 13 | +| **Micrometer + metrics registries** | Counters, timers, tags accumulate | |
| 14 | +| **Redis + caches + in-memory maps** | authoritative + cached state | |
| 15 | +| **GraphQL + GraphiQL** | Extra schema + reflection + servlet overhead | |
| 16 | + |
| 17 | +## Steps Taken to Optimize Memory Usage |
| 18 | + |
| 19 | +### 1. Capping the JVM heap on Railway |
| 20 | + |
| 21 | +Added the following environmental variable on Railway: |
| 22 | +``` |
| 23 | +JAVA_TOOL_OPTIONS=-Xms64m -Xmx256m -XX:+UseG1GC -XX:MaxMetaspaceSize=128m |
| 24 | +``` |
| 25 | +An explicit limit on the JVM's memory allocation w/ the heap and metaspace. This will prevent over-reservation and reducing idle memory use. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +### 2. Disable GraphiQL in Production |
| 30 | + |
| 31 | +Adding this to `application-prod.yml`: |
| 32 | +```yaml |
| 33 | +spring: |
| 34 | + graphql: |
| 35 | + graphiql: |
| 36 | + enabled: false |
| 37 | +``` |
| 38 | +GraphiQL loads static assets, schema introspection, and additional servlet mappings that are unnecessary in production. Disabling it reduces baseline memory usage and class loading overhead. |
| 39 | +
|
| 40 | +--- |
| 41 | +
|
| 42 | +### 3. Limiting Actuator & Micrometer Exposure |
| 43 | +Original version of what's shown below included health, metrics, and prometheus in **include**: |
| 44 | +```yaml |
| 45 | +management: |
| 46 | + endpoints: |
| 47 | + web: |
| 48 | + exposure: |
| 49 | + include: health,prometheus |
| 50 | + metrics: |
| 51 | + enable: |
| 52 | + jvm: false |
| 53 | + process: false |
| 54 | + system: false |
| 55 | + executor: false |
| 56 | + hibernate: false |
| 57 | + logback: false |
| 58 | +``` |
| 59 | +This disables high-cardinality, always-on metric groups (JVM, system, Hibernate, etc.) that consume memory continuously, while preserving the `/prometheus` endpoint needed for future Grafana integration. |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +### 4. Remove Legacy In-Memory Task Queue |
| 64 | +I originally had these fields and methods marked as `@Deprecated` but unfortunately these still consume memory even if they're not in use. |
| 65 | + |
| 66 | +- Removed: |
| 67 | + |
| 68 | + ```java |
| 69 | + private final ConcurrentHashMap<String, Task> jobs; |
| 70 | + ``` |
| 71 | +- Removed deprecated methods such as: |
| 72 | + |
| 73 | + ```java |
| 74 | + @Deprecated |
| 75 | + public List<Task> getJobs() { |
| 76 | + return new ArrayList<>(jobs.values()); |
| 77 | + } |
| 78 | + ``` |
| 79 | + |
| 80 | +The legacy in-memory task map retained domain objects and enabled heap copying under load. Removing it eliminates unnecessary object retention and reinforces PostgreSQL as the single source of truth. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +### 5. Cap Worker and Scheduler Threads via Configuration |
| 85 | +Setting this explicit configuration in `application-prod.yml` for my `QueueProperties.java` file: |
| 86 | +```yaml |
| 87 | +queue: |
| 88 | + main-exec-worker-count: 5 |
| 89 | + sched-exec-worker-count: 2 |
| 90 | +``` |
| 91 | +Thread stacks consume ~1MB each by default. Explicitly capping worker and scheduler counts prevents unbounded thread creation while still allowing meaningful concurrency for demos and testing. |
| 92 | + |
| 93 | +--- |
| 94 | + |
| 95 | +### 6. Removing Unnecessary Dependencies (pom.xml) |
| 96 | + |
| 97 | +Getting rid of stuff like this that was lying around: |
| 98 | +```xml |
| 99 | +<artifactId>spring-boot-starter-webflux</artifactId> |
| 100 | +<scope>test</scope> |
| 101 | +``` |
| 102 | +*For this example specifically*, WebFlux pulls in Reactor and Netty, increasing classpath scanning, memory usage, and startup overhead even when unused. |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +### 7. Replace Executor Factories with Explicit ThreadPoolExecutor |
| 107 | + |
| 108 | +Making this change in `ExecutorConfig.java` (*the original code is what's commented out in the snippet*): |
| 109 | + |
| 110 | +```java |
| 111 | +@Bean("execService") |
| 112 | +public ExecutorService taskExecutor() { |
| 113 | + /*return Executors.newFixedThreadPool(props.getMainExecWorkerCount(), r -> { |
| 114 | + Thread t = new Thread(r); |
| 115 | + t.setName("QS-Worker-" + t.getId()); |
| 116 | + return t; |
| 117 | + });*/ |
| 118 | + return new ThreadPoolExecutor( |
| 119 | + props.getMainExecWorkerCount(), |
| 120 | + props.getMainExecWorkerCount(), |
| 121 | + 0L, |
| 122 | + TimeUnit.MILLISECONDS, |
| 123 | + new LinkedBlockingQueue<>(1000), |
| 124 | + r -> { |
| 125 | + Thread t = new Thread(r); |
| 126 | + t.setName("QS-Worker-" + t.getId()); |
| 127 | + t.setDaemon(true); |
| 128 | + return t; |
| 129 | + } |
| 130 | + ); |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +Using an explicit `ThreadPoolExecutor` avoids unbounded task queues, enables backpressure under load, and reduces memory spikes during stress testing. |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +### 8. Remove Legacy Metrics |
| 139 | + |
| 140 | +Removed counters and gauges tied to deprecated code paths (e.g., legacy in-memory queue metrics). Got rid of stuff like (which relates to outdated, deprecated code): |
| 141 | +```java |
| 142 | +@Bean |
| 143 | + public Gauge inMemoryQueueSizeGauge(MeterRegistry registry, QueueService queueService) { |
| 144 | + return Gauge.builder("springqpro_queue_memory_size", queueService, q -> q.getJobMapCount()) |
| 145 | + .description("Number of tasks currently in legacy in-memory queue") |
| 146 | + .register(registry); |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +Legacy metrics retained references to unused services and data structures, increasing object retention and complicating the runtime memory graph. |
0 commit comments