Event loops are usually associated with JavaScript, Netty, Redis, or UI frameworks. But the idea is useful in everyday backend code too.

Recently I needed a small component with a simple job: accept work from many places, process it in one controlled place, avoid duplicate effort, and shut down cleanly. A thread pool felt too loose because any worker could pick up anything at any time. A scheduled job felt too indirect because the work was not purely time-based. What I wanted was a tiny coordinator.

That is a good place for an event loop.

With Java virtual threads, this pattern becomes easy to write in a direct blocking style. You do not need callbacks. You do not need a custom runtime. You can write normal Java code that waits, wakes up, processes work, and waits again.

What Is an Event Loop?

An event loop is a long-running loop that repeatedly:

  1. waits for an event;
  2. updates local state;
  3. decides what work should happen;
  4. performs that work;
  5. goes back to waiting.

The useful part is not the loop itself. The useful part is that one place owns the coordination.

Instead of many threads directly mutating the same workflow, other parts of the application send signals to the loop. The loop receives those signals and decides what to do next.

A Small Story

Imagine a document search system.

Whenever a document changes, different parts of the application ask for that document to be re-indexed. The same document may be updated multiple times in a few seconds, so we do not want to index it five times. We only need to know that the document is dirty and should be refreshed once.

The design can be:

  • request threads call requestReindex(documentId);
  • the event loop collects document IDs;
  • the loop drains a small burst of pending IDs;
  • duplicate IDs are naturally collapsed by a Set;
  • one loop performs the indexing work;
  • shutdown wakes the loop and lets it exit cleanly.

This is intentionally simple. The point is to learn the shape.

Starting a Virtual-Thread Event Loop

In Java 21+, you can start a virtual thread like this:

1
2
3
Thread.ofVirtual()
        .name("search-index-loop")
        .start(this::runEventLoop);

The loop can block on a queue without occupying a platform thread for the whole wait.

Here is a complete example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

public final class SearchIndexLoop implements AutoCloseable {
    // A special queue item used to wake the loop during shutdown.
    private static final String POISON_PILL = "__shutdown__";

    private final LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();
    private final AtomicBoolean running = new AtomicBoolean(true);
    private final Thread thread;

    public SearchIndexLoop() {
        this.thread = Thread.ofVirtual()
                .name("search-index-loop")
                .start(this::runEventLoop);
    }

    public void requestReindex(String documentId) {
        if (documentId == null || documentId.isBlank()) {
            return;
        }
        // Producers only enqueue work. The event loop owns when work is executed.
        queue.offer(documentId);
    }

    private void runEventLoop() {
        while (running.get()) {
            try {
                // Blocks here when there is no work. With a virtual thread, this
                // does not keep an operating-system thread busy.
                String firstDocumentId = queue.take();

                if (POISON_PILL.equals(firstDocumentId)) {
                    return;
                }

                Set<String> documentIds = collectReadyDocuments(firstDocumentId);
                reindex(documentIds);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            } catch (Exception e) {
                System.err.println("Search index loop failed: " + e.getMessage());
            }
        }
    }

    private Set<String> collectReadyDocuments(String firstDocumentId) {
        Set<String> documentIds = new HashSet<>();
        documentIds.add(firstDocumentId);

        // Grab whatever else is already waiting, without blocking again.
        // The Set naturally removes duplicate document IDs.
        queue.drainTo(documentIds, 100);
        documentIds.remove(POISON_PILL);

        return documentIds;
    }

    private void reindex(Set<String> documentIds) {
        for (String documentId : documentIds) {
            System.out.println("Re-indexing document " + documentId);
        }
    }

    @Override
    public void close() throws InterruptedException {
        running.set(false);
        // Wake the loop if it is currently blocked in queue.take().
        queue.offer(POISON_PILL);
        thread.join(Duration.ofSeconds(10));
    }
}

This is the heart of the pattern:

1
2
3
4
5
6
7
// Blocks until the first event arrives.
String firstDocumentId = queue.take();

// Coalesces any work already queued by other producer threads.
Set<String> documentIds = collectReadyDocuments(firstDocumentId);

reindex(documentIds);

The loop sleeps until work arrives. When one item arrives, it grabs whatever else is already waiting. That gives us a small coalescing effect without adding a scheduler, a timer, or a complicated framework.

Why This Is an Event Loop

This class has the same basic structure as larger event-loop systems:

  • a queue receives events;
  • producers never perform the coordinated work directly;
  • one loop owns the workflow;
  • the loop can keep small local decisions close together;
  • shutdown is represented as an event.

The queue is the boundary. Outside code can say, “this document needs work.” Only the loop decides when and how that work is performed.

Why Virtual Threads Help

The loop above blocks on queue.take().

With a platform thread, a blocked loop means one operating-system thread is sitting around doing nothing. That may be fine for one or two loops, but it becomes expensive if you want several independent loops in a service.

With a virtual thread, blocking is cheap. The virtual thread can be parked while it waits, and the carrier platform thread can run other work.

Java 21 finalized virtual threads in JEP 444. Java 25 also benefits from the Java 24 change in JEP 491, which made blocking inside synchronized methods and blocks much friendlier for virtual threads.

The practical advice is still simple:

  • use virtual threads when you want direct blocking code;
  • keep synchronized sections small;
  • do not block while holding a lock if you can avoid it;
  • prefer clear ownership of state over clever shared-state concurrency.

Adding a Wake-Up Signal

Sometimes the event is not the work itself. Sometimes you just want to wake the loop and ask it to re-check state.

For that, use a small signal enum.

1
2
3
4
private enum Signal {
    WAKE_UP,
    SHUTDOWN
}

Then the loop can wait for signals:

1
2
3
4
5
6
7
8
9
Signal signal = signals.take();

if (signal == Signal.SHUTDOWN) {
    return;
}

if (signal == Signal.WAKE_UP) {
    doOneIteration();
}

This version is useful when the real state lives somewhere else, such as an in-memory map, a database, or a cache. The queue does not need to carry the full payload. It only tells the loop, “something changed; take another look.”

A Few Rules I Like

Keep the loop boring. A good event loop is not magical. It is usually a plain while loop with clear steps.

Do not let producer threads do the loop’s job. Producers should enqueue work or send a signal, then return.

Handle exceptions inside the loop. One bad event should not silently kill the coordinator.

Make shutdown explicit. A blocked loop should always have a way to wake up and exit.

Keep long-running business logic outside locks. The loop can coordinate state, but expensive I/O should not happen while holding a monitor.

When To Use This Pattern

Use an event loop when you need one component to own a small stateful workflow. It is especially nice when many threads can request work, but only one place should decide how that work is processed.

Avoid it when every task is independent and can safely run in parallel. In that case, a normal executor or virtual thread per task is often simpler.

Final Thoughts

The event-loop pattern is not only for framework authors. It is a practical tool for application code too.

Java virtual threads make the pattern feel natural because blocking is no longer something you have to hide behind callbacks. You can write the loop the way you think about the workflow: wait for an event, collect the current state, do the work, and wait again.