Thread safety in servlets sounds dry until it costs you a weekend. This is a practitioner’s view on why shared state is a trap, how to avoid it, and how to keep your Java web app sane on Tomcat or Jetty without playing whack a mole with race bugs.
The night the cart switched owners
Yesterday I watched two users swap shopping carts in production. Not on purpose. Same build, same Tomcat, same database. Different cities. Same item number popping in and out like a magic trick. Support kept asking if we had a cache. We did. That was not the problem. The problem was us.
The giveaway was in the logs. Requests from different sessions were touching the same in memory object. No malicious intent. No weird proxy. Just a servlet with a quick helper field that we thought would save a lookup. It did save a lookup. It also saved a pointer for everyone who hit that servlet at the same time. Two cores turned into four last week and the race went from rare to lottery winner.
We rolled back, pushed a small fix, and the ghost carts vanished. Sleep returned. The lesson stuck. Never share mutable state inside a servlet. The container will share your servlet instance across threads and it will not ask for permission.
What the servlet container actually does
If you keep only one technical idea from this post, make it this one. There is typically one servlet instance and many threads. Tomcat, Jetty, Resin, and friends will create a single instance of your servlet and then call service from a thread pool for each request. You are not getting a new object per hit. You are getting concurrent calls into the same object.
That means any instance field you put in there is a shared thing. Same with a static field. Same with an object placed in ServletContext. The only safe places by default are local variables inside methods and data that is truly immutable.
Where bugs hide
These are the common traps I see in code reviews and late night fire drills.
- Helper objects in fields: That SimpleDateFormat in a field looks innocent. It is not. It keeps internal state and will corrupt output when used by many threads.
- Reusable buffers: A byte array in the servlet to avoid new calls will eventually be shared by two requests. That is not a gift.
- Static caches without guards: A map that grows on demand without concurrency control will throw you into races and visibility problems.
- ThreadLocal for everything: Looks tidy. In containers with thread pools it can leak across redeploys if you forget to clean up. It also hides flow. Use it with care for request scoped things you cannot pass around easily, then clear it.
- Session as a safe box: The session is per user, but a single user can open two tabs. Two concurrent requests can mutate the same object inside the session at once.
The safe playbook
The model that keeps you sane is simple. Stateless servlets. Do work with local variables. If you need configuration, load it once into an immutable object. If you need shared services, grab them from the container and make sure they are safe for many threads.
- Keep state in method scope: Local variables are thread confined. Each request gets its own.
- Prefer immutable data: Value objects with final fields and no setters are your friend. Replace the whole reference when you need to change configuration.
- Use container resources: Get a DataSource via JNDI. Connections are pooled and you borrow per request. Do not stash a Connection in a field.
- Guard shared collections: If a shared map is required, use a concurrent variant and design updates so they do not interleave badly. Better yet, precompute and swap references.
- Careful with ThreadLocal: Limit it to per request things like a correlation id. Clear it in a finally block or in a filter so there is no leftover in the thread pool.
- Session discipline: Keep session objects small and simple. If you must mutate a complex object, replace it rather than editing it in place.
Why synchronized is not a silver bullet
Putting synchronized on a method might silence the bug on your laptop. In production it can bottle neck all requests through a single door. That shows up as random slowness, timeouts under load, and support tickets. Some state needs coordination, but avoid locks around big chunks of work. Design so there is nothing to lock. When you must lock, keep it tight and local.
Visibility is also a thing. Without proper publication other threads might not see updates. Immutable data avoids that hazard by design. For counters and similar use cases, atomic types are simple and boring. Boring here is a compliment.
Servlet scopes without surprises
- Request scope: Safe. Each hit gets its own request and response. Put attributes here freely.
- Session scope: Per user, but concurrent from the same user is possible. Use replacement over in place edits.
- Application scope via ServletContext: Shared by everyone. Treat as read mostly or guard it like a vault.
- Filters: Great for cross cutting tasks. They run per request and can centralize things like character encoding, auth checks, or correlation ids without storing global state.
What changed with more cores
Not long ago a production box with one core hid a lot of sins. Now even budget servers ship with many cores, and clouds hand out concurrency like candy. A tiny race that almost never hit now hits all the time. The code did not get worse. The timing window just opens more often. The fix is still the same. Remove shared mutable state from servlets.
For managers and leads
You do not need to read a memory model paper to steer a team away from these bugs. You need habits and guardrails.
- Set a baseline rule: No mutable fields in servlets. Period. Exceptions need a design note.
- Add a review checklist: Scan for static fields, instance fields, ThreadLocal, and direct use of non thread safe classes like SimpleDateFormat.
- Static analysis: Turn on FindBugs and its concurrency checks. Wire it into the build so surprises show up early.
- Load testing: A quick run with JMeter or ab can surface flaky counters and shared buffers long before a release.
- Framework clarity: If you use Spring, Struts, or similar, make sure the team knows which beans are singletons and which are per request. Defaults matter.
- Postmortems that teach: When a race slips through, write down the trigger and the pattern so it does not come back with a new coat of paint.
Budget hint: The cheapest performance win you will get this quarter is removing blocking in hot paths. Stateless code scales better without new hardware. Your users will feel the difference.
Quick self audit you can run today
- Open your servlet classes and scan for instance fields. If any hold mutable objects, move that state into method scope or replace with immutable data.
- Search the codebase for static. Make sure no request data sneaks into static fields. If a cache is static, check that it is safe and that misses are not racing.
- Search for ThreadLocal. Verify it is cleared after each request. If you cannot point to the cleanup, add it.
- Look for SimpleDateFormat, DecimalFormat, or similar. Replace with per call instances or safe wrappers.
- Verify that HttpSession objects are replaced rather than edited in place when many attributes move together.
- Check filters for global flags or counters. If you count requests, use an atomic counter, or better, expose metrics through the container.
Your turn
Pick one servlet and make it boring in the best sense. Strip fields. Load configuration once into an immutable holder. Keep everything else inside the method. Run a small load test with two hundred parallel requests and watch errors drop to zero. Then do the same for the noisiest servlet in your app.
Send this post to the person on your team who loves to add a quick cache in a field. Ask them to trade that trick for a safer pattern. Next week your pager will be quieter. Your users will never know why, which is the best feedback in this line of work.
If you have a war story about a servlet that went wild under load, share it. The rest of us are one helper field away from repeating it.