Multithreading
Parallelism
Software Architecture
Legacy Code
Concurrency

Advice for converting a large monolithic singlethreaded application to a multithreaded architecture?

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Turning a large single-threaded monolith into a multithreaded system is less about sprinkling threads through the code and more about changing the ownership of work, state, and failure. The safest strategy is incremental: identify one isolated bottleneck, give it a concurrency boundary, measure the result, and repeat.

If you try to parallelize everything at once, you usually replace one slow program with one fast but nondeterministic program.

Profile Before You Parallelize

The first job is not coding. It is finding out whether the current bottleneck is CPU, blocking I/O, locking around a shared resource, or simple algorithmic inefficiency.

Typical candidates for concurrency are:

  • independent per-record processing
  • network or database calls that spend time waiting
  • background tasks such as indexing, image conversion, or report generation

Poor candidates are hot paths that mutate large shared graphs of objects in unpredictable order. Those can be parallelized later, but only after the state model is simplified.

Start With Isolation, Not Threads

A monolith often grows around shared mutable state: global caches, singleton services, and objects reused across unrelated workflows. That is the real obstacle.

Before introducing worker threads, separate these concerns:

  • input collection
  • pure computation
  • persistence or external I/O
  • result publication

The more code you can make deterministic and side-effect free, the less locking you need later.

As a rule, if a function can be rewritten to accept immutable input and return immutable output, do that before moving it off the main thread.

Introduce Concurrency at a Boundary

The most reliable first step is usually a work queue plus a fixed-size thread pool. Instead of letting any part of the system create threads directly, route specific jobs through one execution service.

This Java example shows the idea:

java
1import java.util.List;
2import java.util.concurrent.Callable;
3import java.util.concurrent.ExecutorService;
4import java.util.concurrent.Executors;
5import java.util.concurrent.Future;
6
7public class BatchProcessor {
8    record Job(int id, String payload) {}
9
10    static String process(Job job) {
11        return "processed-" + job.id() + "-" + job.payload().toUpperCase();
12    }
13
14    public static void main(String[] args) throws Exception {
15        List<Job> jobs = List.of(
16                new Job(1, "alpha"),
17                new Job(2, "beta"),
18                new Job(3, "gamma")
19        );
20
21        ExecutorService pool = Executors.newFixedThreadPool(2);
22        List<Callable<String>> tasks = jobs.stream()
23                .<Callable<String>>map(job -> () -> process(job))
24                .toList();
25
26        List<Future<String>> futures = pool.invokeAll(tasks);
27        for (Future<String> future : futures) {
28            System.out.println(future.get());
29        }
30        pool.shutdown();
31    }
32}

This is intentionally simple: one bounded pool, one kind of task, no shared mutable state. That pattern is much easier to reason about than letting each subsystem manage its own ad hoc threads.

Separate CPU Work From I/O Work

Do not assume one pool fits everything. CPU-bound tasks want a small pool around the core count. Blocking I/O can need a larger pool or an asynchronous design, because threads spend time waiting.

If you send database calls, file writes, and heavy parsing into the same executor, you can end up with starvation where slow I/O blocks useful computation.

A producer-consumer design often helps:

java
1import java.util.concurrent.BlockingQueue;
2import java.util.concurrent.LinkedBlockingQueue;
3
4public class QueueExample {
5    public static void main(String[] args) throws Exception {
6        BlockingQueue<String> queue = new LinkedBlockingQueue<>();
7
8        Thread producer = new Thread(() -> {
9            for (int i = 0; i < 3; i++) {
10                queue.add("task-" + i);
11            }
12        });
13
14        Thread consumer = new Thread(() -> {
15            try {
16                for (int i = 0; i < 3; i++) {
17                    System.out.println("handling " + queue.take());
18                }
19            } catch (InterruptedException e) {
20                Thread.currentThread().interrupt();
21            }
22        });
23
24        producer.start();
25        consumer.start();
26        producer.join();
27        consumer.join();
28    }
29}

This makes flow control explicit and keeps components loosely coupled.

Make State Ownership Explicit

Every shared object should have an owner, or it should be immutable. If five threads can update the same cache, statistics object, or domain entity, the design is still effectively single-threaded, just with more failure modes.

Useful transitions include:

  • replacing global maps with thread-safe structures only when sharing is required
  • using message passing instead of in-place mutation
  • moving expensive derived data into immutable snapshots

Locks are sometimes necessary, but a lock is not a design. It is a tax you pay after other design options run out.

Roll Out in Slices

A practical migration plan looks like this:

  1. Add profiling and latency metrics to the current single-threaded flow.
  2. Pick one independent stage and run it through a fixed executor.
  3. Make results and failures observable with logs, counters, and timeouts.
  4. Load-test with realistic traffic before parallelizing another stage.

That approach limits blast radius. It also teaches you where the real contention is, instead of where you expected it to be.

Common Pitfalls

The biggest mistake is sharing too much mutable state. A thread pool cannot rescue code that assumes one global timeline of updates.

Another common failure is creating too many threads. Oversubscription increases context switching and often makes the program slower.

Teams also confuse concurrency with safety. Code that "usually works" under a light test run may still contain races, deadlocks, and ordering bugs that appear only under load.

Finally, do not parallelize without measurement. Sometimes a database query, serialization format, or algorithm is the real bottleneck, and threads only make the problem harder to inspect.

Summary

  • Convert a monolith incrementally, not with a big-bang rewrite.
  • Profile first so you know whether the bottleneck is CPU, I/O, or shared-state contention.
  • Introduce concurrency at clear boundaries such as queues and thread pools.
  • Prefer immutable data and explicit ownership over widespread locking.
  • Measure each step under load before expanding the multithreaded design.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design