Introduction
When building modern web applications with Spring Boot, one of the most crucial architectural decisions you'll make is whether to design your application as stateful or stateless. This choice significantly impacts scalability, performance, security, and maintainability of your application.
In this comprehensive guide, we'll explore both approaches, their trade-offs, and provide practical examples to help you make informed decisions for your Spring Boot applications.
What is State in Web Applications?
Before diving into stateful vs stateless architectures, let's understand what "state" means in the context of web applications.
State refers to any information that an application needs to remember between different requests from the same client. This can include:
- User authentication status
- Shopping cart contents
- User preferences
- Session data
- Temporary form data
- Application workflow progress
Stateful Applications
Definition
A stateful application maintains client state on the server side. The server remembers previous interactions and uses this information to process subsequent requests. Each client session has associated data stored on the server.
How Stateful Works in Spring Boot
In Spring Boot, stateful behavior is typically implemented using:
- HTTP Sessions — Server-side session storage
- Session Beans — Spring beans with session scope
- In-memory data structures — Storing user data in server memory
- Database sessions — Persisting session data to database
Example: Stateful Shopping Cart
@RestController
@RequestMapping("/api/cart")
public class StatefulCartController {
@PostMapping("/add")
public ResponseEntity<String> addToCart(
@RequestBody CartItem item,
HttpServletRequest request) {
HttpSession session = request.getSession(true);
List<CartItem> cart = (List<CartItem>) session.getAttribute("cart");
if (cart == null) {
cart = new ArrayList<>();
}
cart.add(item);
session.setAttribute("cart", cart);
return ResponseEntity.ok("Item added to cart");
}
@GetMapping("/items")
public ResponseEntity<List<CartItem>> getCartItems(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return ResponseEntity.ok(new ArrayList<>());
}
List<CartItem> cart = (List<CartItem>) session.getAttribute("cart");
return ResponseEntity.ok(cart != null ? cart : new ArrayList<>());
}
}Session-Scoped Bean Example
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSession {
private String userId;
private String username;
private List<String> permissions;
private LocalDateTime loginTime;
// Getters and setters
public void initializeSession(String userId, String username) {
this.userId = userId;
this.username = username;
this.loginTime = LocalDateTime.now();
this.permissions = loadUserPermissions(userId);
}
public boolean hasPermission(String permission) {
return permissions != null && permissions.contains(permission);
}
private List<String> loadUserPermissions(String userId) {
// Load permissions from database
return Arrays.asList("READ", "WRITE");
}
}
@Service
public class UserService {
@Autowired
private UserSession userSession;
public boolean canAccessResource(String resource) {
return userSession.hasPermission("READ");
}
}Advantages of Stateful Applications
- Simplified Development: Easier to implement complex user workflows
- Better User Experience: Can maintain context across requests
- Reduced Database Calls: Frequently accessed data cached in session
- Complex State Management: Natural handling of multi-step processes
Disadvantages of Stateful Applications
- Poor Scalability: Difficult to scale horizontally
- Memory Consumption: Server memory usage grows with active sessions
- Single Point of Failure: Session data loss on server restart
- Load Balancer Complexity: Requires sticky sessions
- Resource Cleanup: Need to manage session lifecycle
Stateless Applications
Definition
A stateless application doesn't store any client-specific data on the server between requests. Each request contains all the information needed to process it completely. The server treats each request independently.
How Stateless Works in Spring Boot
Stateless applications in Spring Boot typically use:
- JWT Tokens — Self-contained authentication tokens
- Database lookups — Fetch required data for each request
- External state stores — Redis, external databases
- Request-contained data — All necessary data in request payload
Example: Stateless Authentication with JWT
@RestController
@RequestMapping("/api/auth")
public class StatelessAuthController {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
// Validate credentials
User user = userService.validateCredentials(request.getUsername(), request.getPassword());
if (user != null) {
// Generate JWT token with user information
String token = jwtTokenUtil.generateToken(user);
return ResponseEntity.ok(new AuthResponse(token, user.getUsername()));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
@Component
public class JwtTokenUtil {
private String secret = "mySecretKey";
private int jwtExpiration = 86400; // 24 hours
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
claims.put("roles", user.getRoles());
return createToken(claims, user.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
}JWT Authentication Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtTokenUtil.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
logger.error("Unable to get JWT Token");
} catch (ExpiredJwtException e) {
logger.error("JWT Token has expired");
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}Stateless API Example
@RestController
@RequestMapping("/api/orders")
public class StatelessOrderController {
@Autowired
private OrderService orderService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@PostMapping
public ResponseEntity<Order> createOrder(
@RequestBody CreateOrderRequest request,
@RequestHeader("Authorization") String authHeader) {
// Extract user information from JWT token
String token = authHeader.substring(7);
String userId = jwtTokenUtil.getClaimFromToken(token, claims ->
claims.get("userId", String.class));
Order order = orderService.createOrder(userId, request);
return ResponseEntity.ok(order);
}
@GetMapping("/{orderId}")
public ResponseEntity<Order> getOrder(
@PathVariable String orderId,
@RequestHeader("Authorization") String authHeader) {
String token = authHeader.substring(7);
String userId = jwtTokenUtil.getClaimFromToken(token, claims ->
claims.get("userId", String.class));
Order order = orderService.getOrderByIdAndUserId(orderId, userId);
if (order != null) {
return ResponseEntity.ok(order);
}
return ResponseEntity.notFound().build();
}
}Advantages of Stateless Applications
- Excellent Scalability: Easy to scale horizontally
- High Availability: No single point of failure
- Load Balancer Friendly: Any server can handle any request
- Fault Tolerance: Server failures don't lose user state
- Simpler Deployment: No session synchronization needed
- Microservices Ready: Perfect for distributed architectures
Disadvantages of Stateless Applications
- Increased Network Traffic: More data sent with each request
- Database Load: More frequent database queries
- Complex State Management: Difficult to maintain complex workflows
- Security Considerations: Token management and validation overhead
- Development Complexity: Need to design self-contained requests
Configuration Examples
Stateful Configuration
@Configuration
@EnableWebSecurity
public class StatefulSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
)
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.and()
.logout()
.logoutSuccessUrl("/login")
.invalidateHttpSession(true);
return http.build();
}
}
# application.yml for stateful
server:
servlet:
session:
timeout: 30m
cookie:
http-only: true
secure: true
same-site: strictStateless Configuration
@Configuration
@EnableWebSecurity
public class StatelessSecurityConfig {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
# application.yml for stateless
jwt:
secret: myJWTSecretKey
expiration: 86400
spring:
session:
store-type: noneHybrid Approaches
Using Redis for Distributed Sessions
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("localhost", 6379));
}
}Caching with Stateless Design
@Service
@CacheConfig(cacheNames = "users")
public class UserService {
@Cacheable(key = "#userId")
public User getUserById(String userId) {
// Database call - cached result
return userRepository.findById(userId);
}
@CacheEvict(key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
}When to Choose Stateful vs Stateless
Choose Stateful When:
- Building traditional web applications with server-side rendering
- Complex user workflows with multiple steps
- Limited number of concurrent users
- Need for real-time features with server-pushed updates
- Legacy system integration requirements
Choose Stateless When:
- Building REST APIs or microservices
- Need high scalability and availability
- Planning for cloud deployment
- Multiple client types (web, mobile, IoT)
- Distributed system architecture
Best Practices
For Stateful Applications:
- Session Management: Use appropriate session timeout values
- Memory Management: Monitor and limit session data size
- Clustering: Implement session replication for high availability
- Security: Secure session cookies and implement CSRF protection
- Cleanup: Implement proper session invalidation
For Stateless Applications:
- Token Security: Use strong signing algorithms for JWTs
- Token Expiration: Implement appropriate token lifecycle management
- Validation: Always validate tokens on each request
- Error Handling: Graceful handling of expired or invalid tokens
- Performance: Cache frequently accessed data
Performance Considerations
Stateful Performance Tips:
// Optimize session attribute access
@Service
public class OptimizedStatefulService {
public void processRequest(HttpServletRequest request) {
HttpSession session = request.getSession(false);
// Cache frequently accessed session data
UserContext userContext = (UserContext) session.getAttribute("userContext");
if (userContext == null) {
userContext = loadUserContext();
session.setAttribute("userContext", userContext);
}
// Use cached data
processWithContext(userContext);
}
}Stateless Performance Tips:
// Optimize JWT parsing
@Component
public class OptimizedJwtUtil {
private final Cache<String, Claims> tokenCache =
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Claims getClaimsFromToken(String token) {
return tokenCache.get(token, this::parseToken);
}
private Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}Testing Approaches
Testing Stateful Applications:
@SpringBootTest
@AutoConfigureMockMvc
class StatefulControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void testStatefulWorkflow() throws Exception {
// First request - establish session
MvcResult result = mockMvc.perform(post("/api/login")
.content("{\"username\":\"user\",\"password\":\"pass\"}")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
// Extract session
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
// Subsequent request using same session
mockMvc.perform(get("/api/profile")
.session(session))
.andExpect(status().isOk());
}
}Testing Stateless Applications:
@SpringBootTest
@AutoConfigureMockMvc
class StatelessControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Test
void testStatelessAPI() throws Exception {
// Generate test token
String token = jwtTokenUtil.generateToken(createTestUser());
// Test API with token
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
}Conclusion
The choice between stateful and stateless architecture in Spring Boot depends on your specific requirements, scalability needs, and system architecture. Stateful applications offer simplicity and rich user experiences but sacrifice scalability. Stateless applications provide excellent scalability and fault tolerance but require more careful design.
Modern applications often benefit from a hybrid approach, using stateless design for APIs while leveraging distributed caching and external state stores when needed. Understanding both patterns allows you to make informed architectural decisions that align with your application's goals and constraints.
Remember that architectural patterns are tools in your toolkit. The best choice depends on your specific context, team expertise, performance requirements, and long-term maintenance considerations.