Back to Thread Pool: Sizing & Pitfalls
Q6Question 6 of 7

Per-request memory overhead in Spring Boot?

Key Concepts
  • Thread stack reserves 512 KB to 1 MB of native memory per worker thread, controlled by -Xss.
  • The deserialized request object graph (DTO, entities, collections) lives on the heap until the request ends.
  • JSON serialization buffers peak at roughly twice the response payload because the object graph and bytes coexist.
  • 200 Tomcat threads at 1 MB each reserve about 200 MB of stack before a single request arrives.
  • Heap-diff measurement forces GC, runs N requests, forces GC again, and divides the difference by N.
  • Actuator plus Micrometer exposes allocation rate, which divided by throughput gives bytes allocated per request.
  • AOP around-advice on controllers samples per-endpoint heap delta for attribution.

Definition

One Spring Boot request costs a thread stack plus its object graph plus about twice the JSON payload, and you measure it with heap diffs, Actuator metrics, and AOP sampling.
  • Thread stack – each Tomcat worker thread reserves native memory (commonly 512 KB to 1 MB via -Xss), attributed to the request occupying that thread.
  • Request object graph – Jackson deserializes the JSON body into a DTO, which lives on the heap until the request completes.
  • Business-logic objects – entities loaded from the database, intermediate collections, mapping DTOs, and stream pipelines.
  • JSON serialization buffers – the response object graph and its serialized byte form coexist during writing, peaking at roughly twice the response payload.
  • Measurement – heap diffs give rough retained bytes per request, Actuator plus Micrometer gives allocation rate, and AOP around-advice gives per-endpoint attribution.

Think of one request as one customer walking into National Bank of India. The customer needs a desk reserved the moment they enter (the thread stack), a copy of their filled-in form (the request object), the papers the teller pulls and scratch calculations (domain objects and collections), and a printed receipt plus the carbon copy made while printing (the JSON buffers, which briefly need twice the paper). If you want to know how much paper and desk space one customer costs, you count the supplies before and after serving a thousand customers and divide, you watch the branch's live supply meter (Actuator), and you station an observer at the door who notes supplies used per customer (AOP).

Understanding the Concepts

What is per-request memory overhead?

Per-request memory overhead is the total heap and stack memory a single HTTP request temporarily consumes while it is being processed, before garbage collection reclaims it. In a thread-per-request Spring Boot model the main sources are the thread stack, the deserialized request body, the business-logic object graph, and the JSON response buffers.

What are the four sources of per-request memory?

The first contributor is the thread stack. Each Tomcat worker thread has a stack whose reserved size is controlled by -Xss, commonly defaulting near one megabyte. This memory is native (off-heap) and is reserved per thread, not per request, but since a request occupies one thread for its duration, you attribute a thread's stack to the in-flight request.

JAVA
// Check effective stack size
System.out.println("Stack size: " + ManagementFactory.getThreadMXBean()
    .getThreadInfo(Thread.currentThread().getId()).getStackTrace().length);

The second contributor is the request object graph. When a JSON body arrives, Jackson deserializes it into your DTO. For a small transfer request this is tiny, but for a batch endpoint that accepts a list of 10,000 trades, the deserialized List can be many megabytes on the heap.

The third contributor is everything the business logic allocates: entities loaded from the database, intermediate collections, streams, string concatenations, and mapping between entities and DTOs.

The fourth contributor is JSON serialization. When Spring writes the response, Jackson walks the object graph and produces bytes. During that process both the live object graph and the growing byte buffer exist at once, so the transient peak is roughly the object graph plus the serialized bytes.

How do you measure with heap diffs?

The heap-diff method is the most direct for offline estimation. You force a garbage collection, read used heap, run a known number N of identical requests, force GC again, read used heap, and divide the difference by N.

JAVA
package com.bank.benchmark;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;

public class PerRequestMemoryBenchmark {

    private static final MemoryMXBean MEM = ManagementFactory.getMemoryMXBean();

    public static void main(String[] args) {
        StatementService service = new StatementService();
        int N = 1_000;

        System.gc();
        long before = MEM.getHeapMemoryUsage().getUsed();

        for (int i = 0; i < N; i++) {
            service.generateStatement("CUR3001");
        }

        System.gc();
        long after = MEM.getHeapMemoryUsage().getUsed();
        long perRequest = (after - before) / N;

        System.out.println("Retained bytes per request: " + perRequest);
    }
}
TEXT
Retained bytes per request: 204800

How do you measure with Spring Boot Actuator and Micrometer?

With spring-boot-starter-actuator and Micrometer you expose JVM memory metrics at /actuator/metrics. The key metric is jvm.gc.memory.allocated, which gives the total allocation rate. Dividing this by request throughput gives bytes allocated per request including garbage.

JAVA
package com.bank.config;

import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MetricsConfig {

    @Bean
    MeterRegistryCustomizer<MeterRegistry> commonTags() {
        return registry -> registry.config()
            .commonTags("service", "statement-api");
    }
}
BASH
# Allocation rate divided by request count gives per-request allocation
curl localhost:8080/actuator/metrics/jvm.gc.memory.allocated

How do you measure with AOP per-endpoint attribution?

An aspect around controller methods samples used heap before and after each call and records the delta to a Micrometer distribution summary. This gives per-endpoint attribution.

JAVA
package com.bank.aspect;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class MemoryProfilingAspect {

    private static final MemoryMXBean MEM = ManagementFactory.getMemoryMXBean();

    @Around("execution(* com.bank.*.controller.*.*(..))")
    public Object profile(ProceedingJoinPoint pjp) throws Throwable {
        long before = MEM.getHeapMemoryUsage().getUsed();
        try {
            return pjp.proceed();
        } finally {
            long delta = MEM.getHeapMemoryUsage().getUsed() - before;
            System.out.println(pjp.getSignature().toShortString()
                + " heap delta: " + delta + " bytes");
        }
    }
}
TEXT
StatementController.getStatement(..) heap delta: 2097152 bytes
BalanceController.getBalance(..) heap delta: 8192 bytes

How do you reduce per-request memory?

Streaming the response avoids holding the whole byte array in memory. Use StreamingResponseBody or Jackson's streaming API for large payloads.

JAVA
package com.bank.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@RestController
public class StatementController {

    @GetMapping("/api/statement/stream")
    public StreamingResponseBody streamStatement() {
        return outputStream -> {
            // Write chunks instead of holding the full response in memory
            for (int page = 0; page < 100; page++) {
                byte[] chunk = loadPage(page);
                outputStream.write(chunk);
                outputStream.flush();
            }
        };
    }

    private byte[] loadPage(int page) {
        return ("Page " + page + " data\n").getBytes();
    }
}

Pagination is the other key lever. Instead of loading 50,000 transactions for Priya's CUR3001, you load 100 at a time, so no single request holds a huge object graph.

What are common mistakes?

The wrong approach is loading entire result sets into memory.

JAVA
// WRONG: loads all transactions into one list
List<Transaction> all = transactionRepo.findByAccount("CUR3001");
return ResponseEntity.ok(all);  // Jackson serializes the entire list at once

The correct approach uses pagination and streaming.

JAVA
// CORRECT: paginated endpoint
@GetMapping("/api/transactions")
public Page<Transaction> getTransactions(
        @RequestParam String account,
        @PageableDefault(size = 50) Pageable pageable) {
    return transactionRepo.findByAccount(account, pageable);
}

What should a senior engineer know?

Per-request measurement is inherently noisy because many requests run concurrently and GC runs asynchronously, so AOP deltas are samples, not exact accounting. The trustworthy signals are allocation rate and retained heap under steady load. Also prefer streaming large responses, avoid loading entire result sets, and use pagination so no single request holds a huge object graph.

What are common interview follow-up questions?

  • How does per-request overhead change with virtual threads versus platform threads?
  • How do you attribute memory to a specific endpoint in a microservice?
  • What is the difference between retained memory and allocated memory per request?
  • How does response compression affect per-request memory?

Visual Overview

DIAGRAM
  ┌──────────────────────────────────────────────────┐
  │              One HTTP Request                     │
  │                                                   │
  │  ┌────────────────────────┐                       │
  │  │   Thread Stack (~1 MB) │  native (off-heap)    │
  │  │   method frames, locals│                       │
  │  └────────────────────────┘                       │
  │                                                   │
  │  ┌────────────────────────┐                       │
  │  │  Request DTO (heap)    │  Jackson deserializes │
  │  │  TransferRequest obj   │  JSON body → DTO      │
  │  └────────────────────────┘                       │
  │                                                   │
  │  ┌────────────────────────┐                       │
  │  │  Business Objects      │  entities, collections│
  │  │  Account, Transaction  │  loaded from DB       │
  │  └────────────────────────┘                       │
  │                                                   │
  │  ┌────────────────────────┐                       │
  │  │  JSON Response Buffer  │  ~2x response payload │
  │  │  object + byte[] coexist│                      │
  │  └────────────────────────┘                       │
  └──────────────────────────────────────────────────┘

  Measurement Methods:
  ┌──────────┐   ┌────────────┐   ┌──────────┐
  │ Heap Diff│   │ Actuator + │   │   AOP    │
  │ GC before│   │ Micrometer │   │  Around  │
  │ N reqs   │──▶│ alloc rate │──▶│  Advice  │
  │ GC after │   │ / throughput│  │ per-endpt│
  │ divide N │   │ = bytes/req│   │  delta   │
  └──────────┘   └────────────┘   └──────────┘

Key Takeaway

When a Spring Boot service handles a request in the classic thread-per-request model, that single request consumes memory from four main sources. First is the worker thread's stack, which is native memory reserved when the thread is created and is commonly around one megabyte, so a pool of two hundred Tomcat threads reserves roughly two hundred megabytes before a single request even arrives. Second is the deserialized request object graph that Jackson builds from the JSON body. Third is everything the business logic allocates while processing, which for a report endpoint can dominate everything else. Fourth is JSON serialization, where the object graph and the growing byte buffer coexist, so the transient peak is roughly twice the response payload.

At National Bank of India our statement endpoint loaded every transaction for an account into memory and serialized a large JSON response, and that doubling during serialization, combined with no pagination, drove long garbage-collection pauses under load. The lesson was to paginate and to stream large responses so no single request ever holds a huge object graph.

To measure per-request overhead I use three complementary techniques. The heap-diff method is the most direct: force a garbage collection, read used heap, run a known number of identical requests, force GC again, read used heap, and divide. In production I rely on Actuator and Micrometer, dividing allocation rate by throughput. For per-endpoint attribution I add an AOP aspect that samples heap before and after each controller call.

The important nuance is that per-request measurement is inherently noisy because many requests run concurrently and garbage collection is asynchronous. The trustworthy signals are allocation rate and retained heap under steady load.

Remember one sentence: one request costs a thread stack plus its object graph plus about twice the JSON payload, and you measure it with heap diffs, Actuator, and AOP.

---

Share
Use ← → arrow keys to navigate