Spring Boot Works Perfectly in Development and Falls Over in Production.

Spring Boot Works Perfectly in Development and Falls Over in Production. The Reason Is Almost Always One of These Defaults Nobody Told You to Change.

Spring Boot is built to get you running in minutes. That is its genius and its trap. The defaults that make local development effortless are the same defaults that take your service down at 2 a.m. under real load. Here are the ones that have burned the most teams I have watched, and exactly what to change.

The service ran beautifully in staging for three weeks.

Then it went to production, traffic arrived, and within an hour the latency graph looked like a cliff. Requests piling up. Threads exhausted. The dashboard showing a database that was barely working and an application that was somehow drowning anyway.

The team spent four hours looking in the wrong place, because they were looking at the database, and the database was fine. The problem was a Spring Boot default they had never touched, because in development it never mattered, and nobody had told them it would matter the instant real concurrency showed up.

This is the most common shape of a Spring Boot production incident. Not a bug in the code. A default that is perfect for a developer running one request at a time on their laptop and quietly catastrophic for a service handling hundreds of concurrent requests. Spring Boot optimizes for the first five minutes of your experience. Production is everything after that, and the defaults do not follow you there.

Here are the ones that matter most.

The connection pool that is too small and you never knew it had a size

Spring Boot uses HikariCP as its connection pool, and the default maximum pool size is ten connections.

Ten. That is the number. In development you never notice, because you are one person making one request at a time and ten is infinitely more than you need. In production, under concurrent load, ten connections becomes the ceiling on how many database operations your entire service can do at once. Request number eleven waits. Request number fifty waits a long time. And here is the part that sends teams down the wrong path for hours: that waiting shows up as latency on the application side while the database sits there barely utilized, because the requests are not slow, they are queued, waiting for a connection that does not exist yet.

The team in the opening was watching a healthy database and a drowning application and could not reconcile the two, because the bottleneck was not the database and not the code. It was a pool of ten sitting between them.

The fix is not simply “make it bigger.” The fix is to size it deliberately. The pool should be large enough to serve your concurrency but not so large that it overwhelms the database’s own connection limit, especially when you have multiple application instances each opening their own pool. A pool of ten per instance across twenty instances is two hundred connections to a database that may only accept a hundred. The right number comes from your actual concurrency and your database’s actual limit, set on purpose, not left at the default that was chosen to be safe for a laptop.

The query that runs once in development and ten thousand times in production

This one is not strictly a default, but it is so woven into how Spring Data JPA behaves that it belongs here: the N plus one query problem.

You have an entity with a relationship. A user with orders, a post with comments. You load a list of them and access the relationship in a loop. In development, with five rows in your table, you never notice that JPA is firing one query to load the list and then one additional query per row to load each relationship. Five rows, six queries, imperceptible. In production, with a thousand rows, that is one thousand and one queries for a single request, and the endpoint that was instant in development takes seconds in production and hammers the database with a query storm that looks, from the outside, like a database problem.

It is not a database problem. It is JPA doing exactly what you told it to do, lazily, one relationship at a time, at a scale you never saw locally. The fix is to fetch what you need in one query when you know you need it, using a join fetch or an entity graph, instead of letting the framework lazy-load its way into a thousand round trips. The discipline is to look at the actual SQL your code generates, at realistic data volumes, before it ships, because the difference between six queries and a thousand is invisible until production hands you the thousand.

The transaction that holds a connection while it waits on something slow

Spring’s @Transactional annotation is one of the most useful things in the framework and one of the most dangerous when misunderstood.

A transaction holds a database connection for its entire duration. That is the whole point of a transaction. The danger is what you put inside it. If you wrap a method in @Transactional and that method makes an external API call, or sends an email, or does any slow non-database work, you are holding a database connection from your pool of ten the entire time that slow thing runs. Under load, every concurrent request doing the same thing is holding a connection while waiting on something that has nothing to do with the database, and your pool drains, and every other request starves waiting for a connection that is being held hostage by an HTTP call to a third party.

This is the failure that looks exactly like the connection pool problem, because it is the connection pool problem, caused by code instead of configuration. The fix is to keep transactions tight: do the database work inside the transaction and the slow external work outside it. The connection should be held for the database operation and released the instant the database work is done, never kept open while you wait on the network.

The exception that returns a 200 and a stack trace to your users

By default, an unhandled exception in a Spring Boot application produces a response that includes a great deal of information that is useful to you and dangerous to expose: the exception type, often a stack trace, internal details about your application’s structure.

In development this is a feature. You want the stack trace. In production it is an information leak that tells an attacker about your internals and gives your users a confusing wall of technical text instead of a clean error. Worse, teams that have not set up proper exception handling often discover that the shape of their error responses is inconsistent, different exceptions producing different formats, because nothing ever imposed a single structure.

The fix is a global exception handler that catches everything, logs the full detail server-side where you need it, and returns a clean, consistent, information-safe error to the client. This is not optional polish. It is the difference between an error response that helps an attacker and one that does not, and between an API that fails predictably and one that fails differently every time.

The actuator endpoint that exposes more than you meant to

Spring Boot Actuator gives you health checks and metrics, which you absolutely want in production. It can also, depending on configuration, expose endpoints that reveal environment variables, configuration properties, and internal details you very much do not want public.

The default exposure has become more conservative over Spring Boot versions, but the trap remains: teams enable Actuator for the health check they need and do not audit what else became reachable. The fix is to be explicit about which endpoints are exposed, to secure the management endpoints behind authentication or a separate port, and to verify, from outside your network, exactly what an unauthenticated request can see. Never assume the defaults match your security expectations. Check what is actually reachable.

The pattern underneath all of these

Notice what every one of these has in common. None of them is a bug. None of them shows up in development. Every one of them is a default chosen to make your first five minutes frictionless, that becomes a liability the moment real concurrency, real data volume, or real adversaries arrive.

That is the actual lesson about Spring Boot in production. The framework is designed to get you running fast, and it succeeds, and the speed hides the fact that “running” and “running in production” are different states with different requirements. The defaults serve the first. You are responsible for the second. The engineers who get burned are the ones who assume that because it worked in staging, it will work under load. The engineers who do not are the ones who know which defaults were chosen for the laptop and change them for the cluster.

Spring Boot does not tell you which defaults will hurt you, because at the moment you are setting up your project, they are not hurting you. They are helping. The bill comes later, under load, at 2 a.m., and it is always one of a small set of the same defaults, the same N plus one, the same held connection, every time.

Knowing which ones, before production teaches you the hard way, is most of what separates a Spring Boot service that survives its first real traffic from one that does not.

I collected the production defaults, the failure patterns, and the diagnostic order for finding them fast in Spring Boot Production System OS, the things Spring does not tell you until production does. I write the longer breakdowns through my newsletter.


Spring Boot Works Perfectly in Development and Falls Over in Production. was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.

This post first appeared on Read More