Your Programs Are Not Single-Threaded

Other languages: Español Français Deutsch 日本語 한국어 Português 中文

When writing code, we tend to think synchronously. We write one statement after another, and the runtime executes them in order. This mental model works great for scripts and command-line tools, but it can be dangerously misleading when working with frameworks and server applications.

Even if you never explicitly create threads or use concurrency primitives, your web application is almost certainly running code in parallel. The framework handles it for you, which is convenient, as long as you keep that in mind and correctly use the threading model and the building blocks that the framework provides.

In this article, we’ll look at exactly the kind of bug that might happen when one mistakenly follows a single-threaded mental model in seemingly synchronous code. We’ll find the cause, reproduce it using the debugger, and look at several possible solutions.

The problem

Imagine you have a Spring Boot application that has been running fine in development and production. For a long time, everything has been working smoothly, but then the accounting team reports a serious issue: several invoices have the same number. This is bad. Invoice numbers must be unique for legal and accounting reasons. The duplicates only appeared once, and you can’t reproduce the issue locally.

Tip icon

To make this exercise more interesting, you can try to find the issue yourself before reading further. The project is available on GitHub.

You check the database, and it shows distinct records with identical invoice numbers. The configuration looks fine, yet something is clearly wrong. By tracing the invoice creation flow, you find the service responsible for generating invoice numbers.

Here’s what the code looks like:

@Service
public class InvoiceService {
    private InvoiceNumberGenerator generator;

    public Invoice createInvoice(Order order) {
        if (generator == null) {
            generator = createGenerator();
        }
        String invoiceNumber = generator.nextNumber();
        return new Invoice(invoiceNumber, order);
    }

    private InvoiceNumberGenerator createGenerator() {
        // Reads last used number from database, initializes counter
        int lastNumber = invoiceRepository.findMaxInvoiceNumber();
        return new InvoiceNumberGenerator(lastNumber);
    }
}

This is not how you’d write that code. Can you spot the problem?

What went wrong?

The mental model of the author probably was: “Spring manages my beans, so I’m safe.” And that’s true: Spring indeed handles the bean lifecycle and ensures that InvoiceService is created once and shared properly. But Spring doesn’t know about fields you initialize yourself. The generator field is your responsibility, and this is where the problem lurks.

The sequence of events

Spring beans are singletons by default. This means there’s only one instance of InvoiceService shared across all requests. When multiple HTTP requests come in simultaneously, they’re handled by different threads, all accessing the same service instance.

Now, let’s trace through what happens if the generator hasn’t been initialized yet, and two threads call createInvoice() at roughly the same time:

  1. Thread A checks generator == null , which is true
  2. Thread A enters the if block and starts creating the generator
  3. Thread B checks generator == null , which is still true (A hasn’t created the generator yet)
  4. Thread B also enters the if block
  5. Both threads query the database for findMaxInvoiceNumber() and get the same value, for example, 1000
  6. Both threads create their own InvoiceNumberGenerator starting at 1000
  7. Both call nextNumber() and get 1001
  8. Two invoices are created with the same number

The lazy initialization isn’t thread-safe, and under concurrent access, both threads might read the same “last invoice number” from the database and generate the same next number.

Reproducing with the debugger

The problematic code we just found is simple, so you might understand the sequence of events by just looking at it. In more complex cases, you might want to reproduce the bug step-by-step to understand exactly what’s happening. Other times, you might just want to simulate the unlucky timing and test if the program doesn’t break under load.

We’ll use the IntelliJ IDEA’s debugger for this. It can control individual threads – exactly what we need.

First, set a breakpoint at the generator = createGenerator(); line, right-click it, and set Suspend to Thread. What this does is tell the debugger to only suspend the thread that hit the breakpoint rather than all of them.

Breakpoint settings dialog with Suspend policy set to Thread Breakpoint settings dialog with Suspend policy set to Thread

Run the application in debug mode and send two requests from different terminals using the following curl command:

curl -X POST http://localhost:8080/api/diagnostics/create-order

One of the requests will hit the breakpoint: this is expected. When the second request also hits the breakpoint, you’ve just proven that multiple threads can enter the critical section simultaneously – the definition of the bug at hand.

You can inspect the Threads tab to see both threads waiting at the same location:

Threads tab showing two threads stopped at the same breakpoint location Threads tab showing two threads stopped at the same breakpoint location

Both threads have passed the null check, and when you release them, they will create their own generator instances.

Alternatively, you can use a non-suspending logging breakpoint with a small delay to simulate slower execution:

Breakpoint with evaluate and log expression containing Thread.sleep to simulate slow execution Breakpoint with evaluate and log expression containing Thread.sleep to simulate slow execution

This type of breakpoint is designed for debug logging, but as we already know, it can be used for introducing pretty much any side effect. With this artificial delay, we don’t have to suspend the application because we have 10 extra seconds for the requests to collide.

Fixing the issue

There are several ways to fix the problem, depending on your requirements:

Option 1: Use the database

For invoice numbers specifically, the correct solution is to let the database handle uniqueness:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public Invoice createInvoice(Order order) {
        // Database generates the number via sequence or auto-increment
        return invoiceRepository.save(new Invoice(order));
    }
}

This eliminates the in-memory counter entirely. The database guarantees uniqueness even across multiple application instances.

Option 2: Eager initialization

If you do need an in-memory generator, make it a Spring bean:

InvoiceNumberGenerator.java:
@Component
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

This is the standard approach in Spring. Singleton beans are created at startup and safely shared across all requests.

Option 3: Use @Lazy

If you really need lazy initialization (for example, when a bean is expensive to create because it loads data or establishes connections), you can use @Lazy to let Spring handle this safely:

InvoiceNumberGenerator.java:
@Component @Lazy
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          @Lazy InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

As a rule of thumb, if you find yourself manually implementing lazy initialization, caching, synchronization, or similar patterns – check if the framework already provides it. Doing it yourself often means fighting the framework and introducing maintenance overhead and subtle bugs.

Summary

Even when we don’t explicitly create threads, our applications often run concurrently. This pattern isn’t unique to Spring or even web development. Anywhere you have an implicit threading model, you’re at risk: web frameworks, UI toolkits, and even standard library APIs all manage threads behind the scenes.

The next time you see intermittent, unexplainable behavior in your application, ask yourself: could this be a threading issue? Most often, the answer is yes.

Happy debugging!

all posts ->