Managing Concurrency with Spring Bean Scopes: A Complete Guide

Singleton Beans, Multiple Threads, Zero Data Leaks: The Counterintuitive Magic Behind Spring's Thread Safety

Managing Concurrency with Spring Bean Scopes: A Complete Guide

"Why is user data leaking between accounts in our production app?" The developer's message hit our Slack channel at 2 AM. The culprit? A seemingly innocent Spring bean storing user information in an instance variable.

You might be wondering: if Spring beans are singletons by default, how do they handle thousands of concurrent requests without catastrophic data contamination? It's a paradox that confuses even experienced developers—and getting it wrong can lead to security breaches, data corruption, and those dreaded middle-of-the-night emergencies.

This guide will demystify Spring's bean scoping system, revealing how the framework elegantly manages concurrent requests despite its singleton-first approach. We'll explore when to break away from the default scope, how to match bean lifecycles to your state management needs, and how to test for the subtle bugs that scope-related issues can create.

The Singleton Paradox: How Spring Handles Concurrent Requests

If you're new to Spring, you might wonder: if beans are singletons by default, how can the framework possibly handle multiple concurrent requests without users seeing each other's data?

The answer lies in stateless design. Spring's architecture encourages developers to create stateless services, where beans don't store request-specific data as instance variables. Instead, data flows through method parameters and return values.

Consider this example of a well-designed stateless service:

@Service
public class TaxCalculatorService {
    public double calculateTax(double income, String state) {
        double taxRate = getTaxRateForState(state);
        return income * taxRate;
    }

    private double getTaxRateForState(String state) {
        return state.equals("IL") ? 0.0495 : 0.05;
    }
}

This service can safely handle concurrent requests because each thread maintains its own execution stack with local variables that don't interfere with other threads.

When Singletons Go Wrong: The Perils of Mutable State

To understand why stateless design matters, consider this problematic implementation:

@Service
public class ShoppingCartService {
    private List<String> items = new ArrayList<>();
    private String username;

    public void setUsername(String username) {
        this.username = username;
    }

    public void addItem(String item) {
        items.add(item);
    }

    public List<String> getItems() {
        return items;
    }

    public void checkout() {
        System.out.println("Processing order for: " + username);
        System.out.println("Items: " + items);
        items.clear();
    }
}

This service stores user-specific data as instance variables. Since it's a singleton, all users share the same instance, leading to these problems:

  1. User A could see User B's items or username

  2. One user's checkout could clear another user's cart

  3. Race conditions could corrupt data as concurrent requests modify the same lists

Understanding Spring Bean Scopes

Spring offers several scopes to address different state management needs:

Singleton Scope (Default)

One instance per Spring container, shared across all requests.

Best for: Stateless services, immutable data, thread-safe utilities

Example use case: Tax calculators, email formatters, validation services

Request Scope

Creates a new instance for each HTTP request.

  • Best for: Request-specific context, tracking request metadata

  • Example use case: Request loggers, audit contexts, request validators

Session Scope

Creates a new instance for each user session.

  • Best for: User-specific data that persists across multiple requests

  • Example use case: Shopping carts, user preferences, multi-step wizards

Prototype Scope

Creates a new instance each time requested.

  • Best for: Stateful components that must be unique

  • Example use case: Complex object builders, non-shared resources

Configuring Bean Scopes in Spring

Configuring a bean's scope is straightforward:

@Component
@RequestScope  // or @SessionScope, or @Scope("prototype")
public class UserContext {
    private String userId;
    private Instant loginTime;

    // Getters and setters
}

Real-World Example: Building a Spring Backend for React

When developing a Spring backend for a React frontend, managing state becomes especially important. Let's look at a practical example that uses different scopes appropriately:

// Singleton - Stateless service
@Service
public class ProductService {
    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    public List<Product> findProducts(String category) {
        return repository.findByCategory(category);
    }
}

// Request-scoped - Tracks request-specific data
@Component
@RequestScope
public class RequestAuditContext {
    private final String requestId = UUID.randomUUID().toString();
    private Instant startTime = Instant.now();

    public void logRequest(String endpoint) {
        long duration = Duration.between(startTime, Instant.now()).toMillis();
        System.out.println("Request " + requestId + " to " + endpoint + " took " + duration + "ms");
    }
}

// Session-scoped - Maintains user state across requests
@Component
@SessionScope
public class ShoppingCart {
    private final List<CartItem> items = new ArrayList<>();

    public void addItem(CartItem item) {
        items.add(item);
    }

    public List<CartItem> getItems() {
        return Collections.unmodifiableList(items);
    }
}

Integration with React Frontend

Spring's session management works well with React frontends, but requires some configuration:

For traditional cookie-based sessions:

@Configuration
public class SessionConfig {
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setSameSite("Lax");
        serializer.setCookiePath("/");
        serializer.setUseSecureCookie(true);
        return serializer;
    }
}

React needs to include credentials with requests:

fetch('https://api.example.com/api/user/profile', {
  credentials: 'include' // Important for sending cookies
})

JWT-Based Authentication

For token-based approaches:

@RestController
public class AuthController {
    @Autowired
    private JwtTokenProvider tokenProvider;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        // Authenticate user
        String token = tokenProvider.generateToken(username);
        return ResponseEntity.ok(new JwtResponse(token));
    }
}

React would store and send the token:

fetch('https://api.example.com/user/cart', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
})

Performance Implications of Bean Scopes

Bean scope selection impacts application performance in several ways:

ScopeMemory UsageCreation CostRelative Performance
SingletonLowestOnce at startupFastest (100%)
RequestMediumOnce per requestMedium (70-90%)
SessionPotentially highOnce per sessionMedium-Fast (80-95%)
PrototypeVariesOn each retrievalSlowest (50-80%)

For most applications, these performance differences are negligible compared to database access and external API calls. Still, high-throughput applications should minimize the use of request-scoped beans on critical paths.

Detecting scope-related problems requires specific testing approaches:

Concurrent Request Testing

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ConcurrentRequestTests {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void testConcurrentRequests() throws InterruptedException {
        int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < threadCount; i++) {
            final String userId = "user" + i;
            new Thread(() -> {
                try {
                    ResponseEntity<String> response = restTemplate.postForEntity(
                        "/api/profile", 
                        Map.of("userId", userId, "action", "update"),
                        String.class
                    );

                    if (response.getBody().contains(userId)) {
                        successCount.incrementAndGet();
                    }
                } finally {
                    latch.countDown();
                }
            }).start();
        }

        latch.await(10, TimeUnit.SECONDS);
        assertEquals(threadCount, successCount.get());
    }
}

Session Contamination Testing

Create tests that verify user A never sees user B's data:

@Test
public void testNoSessionContamination() {
    // Create two separate sessions
    TestRestTemplate userA = new TestRestTemplate();
    TestRestTemplate userB = new TestRestTemplate();

    // Add different items to each cart
    userA.postForEntity("/api/cart/add", new CartItem("item-A"), Void.class);
    userB.postForEntity("/api/cart/add", new CartItem("item-B"), Void.class);

    // Verify each user sees only their own items
    ResponseEntity<Cart> userACart = userA.getForEntity("/api/cart", Cart.class);
    ResponseEntity<Cart> userBCart = userB.getForEntity("/api/cart", Cart.class);

    assertTrue(userACart.getBody().containsItem("item-A"));
    assertFalse(userACart.getBody().containsItem("item-B"));

    assertTrue(userBCart.getBody().containsItem("item-B"));
    assertFalse(userBCart.getBody().containsItem("item-A"));
}

Best Practices for Bean Scope Management

  1. Default to Stateless Singletons: Design services to be stateless when possible

  2. Match Scope to State Requirements:

    • Application-wide state → Singleton with thread-safe collections

    • User-specific state → Session scope

    • Request-specific state → Request scope

  3. Prefer Method Parameters: Pass data through method parameters rather than storing in instance variables

  4. Use ThreadLocal Carefully: For framework code that needs request context without dependency injection

  5. Document Scope Decisions: Add comments or documentation explaining scope choices for non-standard cases

Conclusion

Understanding Spring bean scopes is crucial for building robust, scalable applications that handle concurrent requests correctly. By matching the right scope to your state management needs, you can create applications that are both thread-safe and efficient.

The next time you create a Spring bean, ask yourself: "What is the right scope for this component's state?" By making this a conscious decision rather than accepting the default, you'll build more reliable applications and avoid the subtle bugs that come from improper state management.

Remember, in most cases, following Spring's push toward stateless design with singleton beans is the right approach. Only reach for other scopes when you have a specific state management requirement that demands it.

Did you find this article valuable?

Support Sean Coughlin's Blog by becoming a sponsor. Any amount is appreciated!