Tomcat vs NGINX: What Each Does and Why Spring Boot May Need Both
▶ Watch on YouTube & subscribe to The Stack Underflow
Your Spring Boot app works on localhost. Behind NGINX, uploads fail with 413, the AI answer arrives in chunks, and a long request dies around 60 seconds. To fix any of them, find the layer that owned the request when it failed. The video follows one request, POST /api/chat with a file attached, through the whole stack, and this page walks the same path with the setting and the source behind every step.
The one-line version: NGINX is the traffic and edge layer; Tomcat is the HTTP server and Servlet container your Spring Boot app runs in. A 413, a chunked stream or a cut-off answer each belongs to one of them, and the fix lives in that layer.
Last verified against the NGINX, Spring Boot, Spring Framework, Tomcat and Kubernetes documentation and source: 4 October 2026. Scope: open-source NGINX (nginx.org docs and source), Spring Boot 4.1.1 with spring-boot-starter-webmvc, current Spring Framework, Apache Tomcat 11. Not NGINX Plus.
Four layers, two different jobs
| Layer | What it is | Jobs in this video |
|---|---|---|
| NGINX | The traffic and edge layer | TLS, routing by location, load balancing, static files, caching, limits, timeouts |
| Tomcat | The HTTP server and Servlet container | Accepts the connection, parses HTTP, runs servlets |
| Spring MVC | The web framework | DispatcherServlet, your controller and service, upload limits |
| JVM | The Java runtime | Runs all of the above |
NGINX’s connection to Tomcat is its own, second connection, over http or https. By default NGINX does not pass the original Host header and adds no X-Forwarded-* headers: they reach Tomcat only if you configure them with proxy_set_header. (Spring Boot’s server.forward-headers-strategy then decides how the app reads them.)
Where Tomcat went in Spring Boot
For servlet applications, Spring Boot includes embedded Tomcat and Jetty, and the embedded server listens on port 8080 by default. spring-boot-starter-webmvc brings Spring MVC and Tomcat (older projects use spring-boot-starter-web, now deprecated in its favour). So in a Spring Boot jar, Tomcat isn’t a separate install: it starts inside your JVM.
Two Spring Boot details that differ from standalone Tomcat:
- Static files in a Spring Boot app are served by Spring MVC running inside Tomcat. The container’s own default servlet isn’t enabled in an embedded app (
server.servlet.register-default-servletturns it on). - Spring Boot’s Tomcat defaults: 200 worker threads (
server.tomcat.threads.max) and 8,192 connections (server.tomcat.max-connections).
Standalone Tomcat’s HTTP connector can also run as a stand-alone web server, not only a servlet runner. The point isn’t that Tomcat can’t face the internet; it’s that NGINX is built for the edge jobs.
Do you need NGINX at all?
Not always. Something has to do the front-door jobs: TLS, routing several services by path, static files, rate limits, buffering slow clients. A cloud load balancer, a CDN or a Kubernetes gateway may take over some or all of them. On Kubernetes, note that the project now recommends Gateway over Ingress (the Ingress API is frozen, not removed), and the community Ingress NGINX controller is retired: best-effort maintenance ended in March 2026, and existing deployments keep working. So the thing in front of your pods might be NGINX-based, or might not.
Failure 1 · 413 on upload: every limit in the chain
A 20 MB upload, everything on defaults:
- NGINX first.
client_max_body_sizedefaults to 1 MB; above it NGINX answers 413 itself, and the request never reaches Tomcat. It can be set inhttp,serverorlocation;0disables the check. - Raise only that, and it still fails. Spring Boot allows 1 MB per file (
spring.servlet.multipart.max-file-size) and 10 MB per request (spring.servlet.multipart.max-request-size). - In current Spring, that’s a 413 too.
MaxUploadSizeExceededExceptionis anErrorResponsewith status 413 (“Maximum upload size exceeded”), so the status code alone doesn’t tell you which layer refused.
How to tell them apart: the body is a clue (NGINX’s HTML error page vs Spring’s error / ProblemDetail body), and the logs confirm (NGINX’s error log vs the app log). Older Spring Framework versions may answer differently.
Failure 2 · Why the AI stream arrives in chunks
proxy_buffering is on by default: NGINX reads the response from Tomcat as fast as it can into buffers, and the client gets it in bursts. With buffering off, NGINX passes the response on synchronously, as it arrives.
Two fixes:
proxy_buffering off;on the streaminglocation.- Or let the app send the response header
X-Accel-Buffering: nofor that response, unless NGINX is configured to ignore it withproxy_ignore_headers.
Failure 3 · 60 seconds of silence (and Spring’s 30 seconds)
proxy_read_timeout defaults to 60 s, and it times the gap between two reads, not the whole response. If the upstream sends nothing for that long, NGINX closes the connection. What the client sees depends on when:
| When the silence happens | What the client gets |
|---|---|
| Before the response starts (no headers yet) | 504 Gateway Timeout |
| Mid-stream (the 200 and some data already sent) | The connection just closes; no 504, because the status was already sent |
So a slow endpoint that sends nothing for 60 s gets a 504; a stream that goes quiet for 60 s mid-answer is simply cut off.
A separate limit inside the JVM: a Spring MVC async request (a streamed response) on Spring Boot’s embedded Tomcat ends after 30 seconds in total by default, even while data is flowing. If spring.mvc.async.request-timeout isn’t set, Spring Boot uses the underlying implementation’s default, and Tomcat’s asyncTimeout defaults to the Servlet specification’s 30,000 ms. We checked it on Spring Boot 4.1.1 with one token every 5 s: the default run ended after 31 s; with spring.mvc.async.request-timeout=120s, every token arrived.
502 vs 504: two timelines, where to look
When NGINX can’t get a usable answer from the upstream:
- 504: it timed out waiting (connecting, sending, or reading the response header).
- 502: an error while connecting, sending the request or reading the response header, or the upstream returned an empty or invalid response.
The Server: header is a clue to who answered, not proof: confirm in NGINX’s error log and the app’s log. The NGINX state-machine video walks every 502 and 504 path in detail.
Pause & Prove
1. The pinned question
Your AI answer streams through NGINX with a token every few seconds, and it stops at 30 seconds. NGINX’s proxy_read_timeout is 60 s. Which layer ended the stream, and what would you change?
Not NGINX. proxy_read_timeout times the gap between two reads, and the gaps were only a few seconds. Inside the JVM, a Spring MVC stream on Spring Boot’s embedded Tomcat ends after 30 seconds in total by default. Set spring.mvc.async.request-timeout.
2. The Community poll
A 20 MB upload goes through NGINX to a Spring Boot app. Everything is on its defaults. Who rejects it?
- NGINX, with 413. ✓
client_max_body_sizeis 1 MB by default, so NGINX answers 413 itself and Spring never sees the request. - Spring, with 413. Not on defaults: the request never gets past NGINX. Raise only NGINX’s limit, though, and this becomes the answer: Spring Boot allows 1 MB per file and 10 MB per request, and current Spring answers 413 too.
- Tomcat, with 500. Tomcat never sees this request: NGINX already refused it at the edge.
- Nobody, it works. 20 MB is over both NGINX’s and Spring Boot’s defaults.
Related Shorts
- 413 on upload: you raised the NGINX limit and it still fails
- Your AI stream arrives in chunks behind NGINX: proxy_buffering
- NGINX proxy_read_timeout: not a 60 s limit, 60 s of silence
Before / after this video
- Words first: Backend words: port, socket, reverse proxy, JVM
- Deeper: What Apache Tomcat actually does with your request and What NGINX does before your app sees a request
- Next: Why Your WebSocket Disconnects After 60 Seconds: Spring Boot Behind NGINX, where the same
proxy_read_timeoutcloses a quiet WebSocket (words first: the WebSocket primer)
Sources
Read on 4 October 2026:
- nginx.org, ngx_http_core_module:
client_max_body_size(default 1m, 413,0disables) - nginx.org, ngx_http_proxy_module:
proxy_buffering(default on),X-Accel-Buffering,proxy_ignore_headers,proxy_read_timeout(60 s, between two reads),proxy_pass(http or https),proxy_set_header(Host and Connection not passed by default),proxy_next_upstream(error and invalid_header) - NGINX source,
src/http/ngx_http_upstream.c: timeout → 504, other failures → 502; no error status once the response header has been sent - Spring Boot 4.1.1, Common Application Properties: multipart limits,
spring.mvc.async.request-timeout,server.tomcat.threads.max,server.tomcat.max-connections,server.forward-headers-strategy - Spring Boot 4.1.1, Servlet Web Applications: embedded Tomcat and Jetty, port 8080, default servlet not enabled in an embedded app
- Spring Boot 4.1.1, Build Systems: starters:
spring-boot-starter-webmvc,spring-boot-starter-webdeprecated - Spring Framework source,
MaxUploadSizeExceededException: 413, “Maximum upload size exceeded” - Apache Tomcat 11, HTTP Connector: stand-alone web server,
asyncTimeout(30,000 ms, the Servlet specification default) - Kubernetes, Ingress (Gateway recommended, Ingress frozen, not removed) and the Ingress NGINX retirement post
Change notes
- 4 Oct 2026: first published.
Not affiliated with or endorsed by the Apache Software Foundation, F5 or the nginx project. Apache Tomcat is a trademark of the Apache Software Foundation. NGINX is a trademark of F5, Inc. Found a mistake? Tell us in the video’s comments and we’ll correct this page.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →Prefer email? Get the free newsletter: one failure, traced step by step, about once a week.