Skip to content

How to Use WatchService in Java to Watch a Directory

Build a folder watcher in Java 21 with WatchService. It copes with half-written files, duplicate events and overflow, and ships with 23 passing checks.

how-to-tutorials java-io java-tutorials
Gopi Gorantala
Reading Progress

On This Page

Here's the inbox folder doing its job

A library's returns desk has a scanner that drops one small file per returned book into a folder. Nobody presses a button. A Java process notices each file, reads it, updates the catalogue and files the slip away. Here's the console from the finished program, run against five files dropped in one second:

watching /home/claude/returns/work/inbox
[watcher] processed slip-001.slip
[watcher] processed slip-002.slip
[watcher] processed slip-003.slip
[watcher] rejected slip-004.slip: isbn must be 13 digits: 123
catalogue: returned={9780134685991=2, 9780596009205=1}, damaged=1
inbox now:     [notes.txt]
processed now: [slip-001.slip, slip-002.slip, slip-003.slip]
rejected now:  [slip-004.slip, slip-004.slip.reason]

That's how to use WatchService in Java for a job people actually have: react to files appearing in a folder instead of polling it every few seconds. You'll build it with the JDK alone, about 350 lines including a small catalogue. The part most tutorials leave out is what the watcher does with a file that's still being written, with a burst of 300 files, and with events it can't trust. Those are the parts that break on a real folder, so they're in here.

slip-003.slip in that output was written slowly, in three chunks, straight to its final name. The watcher got an event for it before the member line existed. Step 4 shows why it still came out right.

flowchart LR
    S[Scanner writes<br/>x.slip.tmp] -->|rename| I[inbox/<br/>x.slip]
    I -->|ENTRY_CREATE<br/>ENTRY_MODIFY| W[InboxWatcher thread]
    W -->|wait until size is stable| P[parse the slip]
    P -->|ok| D[processed/]
    P -->|bad| R[rejected/ + .reason]
    P -->|ok| C[Catalogue]

Versions I used

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Operating system Linux, 6.18 kernel (inotify underneath) uname -r
IntelliJ IDEA or Eclipse optional, any current release Help → About
Build tool none; plain javac

Java 16 or newer compiles all of it (the slip is a record); nothing here needs 21 specifically. No Maven, no Gradle, no containers. Everything is in java.nio.file.

I ran every command and every output below on Linux. I did not run any of it on macOS or Windows, and that matters for this particular API, because the JDK picks a different WatchService implementation per operating system. The "How it works" section says what I do know about that.

You should be comfortable with Path and Files (see how to read and write files with NIO.2) and with records (see how to use Java records).

Set up the library-returns project

I used the terminal as the primary path this time, because the watcher has a working-directory quirk (the demo makes a work/ folder relative to where you start it) that's easiest to see on the command line.

From the command line

mkdir -p returns/src/main/java/com/example/returns returns/src/test/java/com/example/returns
cd returns

The project tree once all the files exist:

returns/
├── src/
│   ├── main/java/com/example/returns/
│   │   ├── Catalogue.java
│   │   ├── Demo.java
│   │   ├── InboxWatcher.java
│   │   ├── PrintEvents.java
│   │   └── ReturnSlip.java
│   └── test/java/com/example/returns/
│       └── Checks.java
└── work/            (created by the programs when they run)
    ├── inbox/
    ├── processed/
    └── rejected/

You'll compile with javac --release 21 -d out src/main/java/com/example/returns/*.java from the returns folder. There's nothing to download.

In IntelliJ IDEA

Verified from the documentation, not executed: File → New → Project, choose Java, set the build system to IntelliJ, pick a JDK 21 (the dialog can download one), and name it returns. Create the package com.example.returns under src. Put Checks.java in a second source folder: right-click the project, New → Directory, name it test, then right-click it and choose Mark Directory as → Test Sources Root. For Demo and PrintEvents, open Run → Edit Configurations, and set Working directory to the project root so work/ lands where you expect.

In Eclipse

Verified from the documentation, not executed: File → New → Java Project, uncheck Use default location only if you want a folder of your own, and choose a JavaSE-21 execution environment. Create the package with File → New → Package. For Checks, add a second source folder with right-click project → New → Source Folder. In Run → Run Configurations, the Arguments tab has a Working directory field; leave it on the project.

With Docker or Podman

This project has no container. One caution I haven't tested: if you later run the watcher in a container and the inbox is a folder mounted from your computer, check that events arrive at all. The Javadoc says that changes on non-local storage may not be detected.

Watch a folder with WatchService in Java, six steps

Step 1: Model one slip

Goal: a type for what's inside a .slip file, and a parser that explains what's wrong with a bad one.

src/main/java/com/example/returns/ReturnSlip.java

package com.example.returns;

import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public record ReturnSlip(String isbn, String member, Condition condition) {

    public enum Condition { GOOD, DAMAGED }

    /** Parses the lines of a .slip file. Throws IllegalArgumentException with a readable reason. */
    public static ReturnSlip parse(List<String> lines) {
        Map<String, String> values = new HashMap<>();
        for (String line : lines) {
            String text = line.strip();
            if (text.isEmpty() || text.startsWith("#")) {
                continue;
            }
            int eq = text.indexOf('=');
            if (eq < 1) {
                throw new IllegalArgumentException("not a key=value line: " + text);
            }
            values.put(text.substring(0, eq).strip().toLowerCase(Locale.ROOT),
                       text.substring(eq + 1).strip());
        }
        String isbn = require(values, "isbn");
        if (!isbn.matches("\\d{13}")) {
            throw new IllegalArgumentException("isbn must be 13 digits: " + isbn);
        }
        String member = require(values, "member");
        String raw = values.getOrDefault("condition", "good").toUpperCase(Locale.ROOT);
        Condition condition;
        try {
            condition = Condition.valueOf(raw);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("condition must be good or damaged: " + raw);
        }
        return new ReturnSlip(isbn, member, condition);
    }

    private static String require(Map<String, String> values, String key) {
        String value = values.get(key);
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("missing " + key);
        }
        return value;
    }
}

A slip is three key=value lines: isbn, member, and an optional condition. The parser throws IllegalArgumentException with a message a librarian could read, because that message ends up in the .reason file next to a rejected slip. Keys are case-insensitive, blank lines and # comments are skipped.

Checkpoint: nothing to run yet. You'll compile everything at Step 5.

Step 2: Give the slips somewhere to land

Goal: a catalogue that counts returns, safe to call from the watcher thread while another thread reads it.

src/main/java/com/example/returns/Catalogue.java

package com.example.returns;

import java.util.Map;
import java.util.TreeMap;

/** The part of the library system that cares about returned books. In memory, thread-safe. */
public final class Catalogue {

    private final Map<String, Integer> returned = new TreeMap<>();
    private int damaged;

    public synchronized void recordReturn(ReturnSlip slip) {
        returned.merge(slip.isbn(), 1, Integer::sum);
        if (slip.condition() == ReturnSlip.Condition.DAMAGED) {
            damaged++;
        }
    }

    public synchronized int returnedCount(String isbn) {
        return returned.getOrDefault(isbn, 0);
    }

    public synchronized int totalReturned() {
        return returned.values().stream().mapToInt(Integer::intValue).sum();
    }

    public synchronized int damagedCount() {
        return damaged;
    }

    @Override
    public synchronized String toString() {
        return "returned=" + returned + ", damaged=" + damaged;
    }
}

Every method is synchronized because the watcher thread writes and your main thread will read. For this size, that's the simplest correct thing.

Step 3: See the raw events first

Goal: watch the folder with the bare API, so you know what the operating system actually sends before you build on it.

src/main/java/com/example/returns/PrintEvents.java

package com.example.returns;

import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;

import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;

public class PrintEvents {

    public static void main(String[] args) throws Exception {
        Path inbox = Files.createDirectories(Path.of("work", "inbox"));
        try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
            inbox.register(watcher, ENTRY_CREATE, ENTRY_MODIFY);
            System.out.println("watching " + inbox.toAbsolutePath() + " (Ctrl+C to stop)");
            while (true) {
                WatchKey key = watcher.take();
                for (WatchEvent<?> event : key.pollEvents()) {
                    System.out.println(event.kind() + " " + event.context() + " (count " + event.count() + ")");
                }
                if (!key.reset()) {
                    break;
                }
            }
        }
    }
}

Five calls matter: newWatchService(), register(...) on a directory Path, take() (blocks), pollEvents(), and reset(). Compile and run it:

mkdir -p out
javac --release 21 -d out src/main/java/com/example/returns/PrintEvents.java
java -cp out com.example.returns.PrintEvents

In a second terminal, from the returns folder, create a slip, append to it, and drop a text file:

cd work/inbox
printf 'isbn=9780134685991\nmember=M-1\n' > one.slip
echo "more" >> one.slip
touch ignore.txt

Checkpoint: the first terminal prints this (timing can change how events are grouped, but the kinds are these):

watching /home/claude/returns/work/inbox (Ctrl+C to stop)
ENTRY_CREATE one.slip (count 1)
ENTRY_MODIFY one.slip (count 1)
ENTRY_MODIFY one.slip (count 1)
ENTRY_CREATE ignore.txt (count 1)
ENTRY_MODIFY ignore.txt (count 1)

Look at that carefully. One printf produced a create and a modify. touch on a new file produced a create and a modify too, for a file with nothing in it. The echo produced one more modify. An event says "something happened to this name", never "this file is finished". Everything in Step 4 follows from that.

Stop it with Ctrl+C.

Step 4: Write the watcher that survives real files

Goal: turn raw events into "process each finished slip exactly once".

src/main/java/com/example/returns/InboxWatcher.java

package com.example.returns;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static java.nio.file.StandardWatchEventKinds.OVERFLOW;

import java.io.IOException;
import java.nio.charset.CharacterCodingException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

public final class InboxWatcher implements AutoCloseable {

    private final Path inbox;
    private final Path processed;
    private final Path rejected;
    private final Consumer<ReturnSlip> sink;
    private final Consumer<String> log;
    private final Duration settle;
    private final Duration maxWait;
    private final WatchService watcher;
    private final Thread thread;

    public InboxWatcher(Path inbox, Path processed, Path rejected,
                        Duration settle, Consumer<ReturnSlip> sink, Consumer<String> log)
            throws IOException {
        this.inbox = inbox;
        this.processed = processed;
        this.rejected = rejected;
        this.settle = settle;
        this.maxWait = Duration.ofSeconds(10);
        this.sink = sink;
        this.log = log;
        Files.createDirectories(processed);
        Files.createDirectories(rejected);
        this.watcher = inbox.getFileSystem().newWatchService();
        inbox.register(watcher, ENTRY_CREATE, ENTRY_MODIFY);   // throws if inbox is not a directory
        this.thread = new Thread(this::run, "inbox-watcher");
    }

    public void start() {
        thread.start();
    }

    private void run() {
        try {
            scanExisting();                       // files that arrived before we registered
            while (true) {
                WatchKey key = watcher.take();    // blocks until something happens
                Set<Path> pending = new LinkedHashSet<>();
                boolean overflow = false;
                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == OVERFLOW) {
                        overflow = true;
                        continue;
                    }
                    Path name = (Path) event.context();       // relative: just the file name
                    if (isSlip(name)) {
                        pending.add(inbox.resolve(name));      // a Set folds CREATE + MODIFY into one
                    }
                }
                boolean stillValid = key.reset();              // without this, no more events. Ever.
                if (overflow) {
                    log.accept("overflow: events were lost, rescanning " + inbox);
                    scanExisting();
                }
                for (Path file : pending) {
                    handle(file);
                }
                if (!stillValid) {
                    log.accept("inbox is no longer accessible, stopping");
                    return;
                }
            }
        } catch (ClosedWatchServiceException e) {
            // close() was called: the normal way out
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (IOException e) {
            log.accept("watcher failed: " + e);
        }
    }

    private void scanExisting() throws IOException {
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(inbox, "*.slip")) {
            List<Path> files = new java.util.ArrayList<>();
            stream.forEach(files::add);
            files.sort(null);
            for (Path file : files) {
                handle(file);
            }
        }
    }

    private static boolean isSlip(Path name) {
        return name.toString().endsWith(".slip");
    }

    private void handle(Path file) {
        if (!Files.isRegularFile(file)) {
            return;                               // an earlier event already moved it
        }
        try {
            awaitStable(file);
            ReturnSlip slip = ReturnSlip.parse(Files.readAllLines(file));
            move(file, processed);                // move first, so a failed move never double-counts
            sink.accept(slip);
            log.accept("processed " + file.getFileName());
        } catch (NoSuchFileException e) {
            // moved or deleted while we waited
        } catch (IllegalArgumentException | CharacterCodingException e) {
            reject(file, e.getMessage());
        } catch (IOException e) {
            log.accept("could not handle " + file.getFileName() + ": " + e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** Waits until the file size stops changing. A heuristic. The reliable fix is for writers to rename into place. */
    private void awaitStable(Path file) throws IOException, InterruptedException {
        long deadline = System.nanoTime() + maxWait.toNanos();
        long last = -1;
        while (System.nanoTime() < deadline) {
            long size = Files.size(file);
            if (size == last) {
                return;
            }
            last = size;
            Thread.sleep(settle.toMillis());
        }
        throw new IOException("still changing after " + maxWait);
    }

    private void reject(Path file, String reason) {
        try {
            Files.writeString(rejected.resolve(file.getFileName() + ".reason"), reason + System.lineSeparator());
            move(file, rejected);
            log.accept("rejected " + file.getFileName() + ": " + reason);
        } catch (IOException e) {
            log.accept("could not reject " + file.getFileName() + ": " + e);
        }
    }

    private static void move(Path file, Path targetDir) throws IOException {
        Files.move(file, targetDir.resolve(file.getFileName()), REPLACE_EXISTING);
    }

    @Override
    public void close() throws IOException {
        watcher.close();                           // wakes take() with ClosedWatchServiceException
        try {
            thread.join(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Read it in this order, because the decisions are small but each one is there for a reason.

Register in the constructor, scan in run(). inbox.register(...) happens first, so any file arriving afterwards produces an event. scanExisting() then picks up whatever was already sitting there. A file that lands in the gap between the two gets handled twice; that's fine, because the second handle finds the file already moved and returns.

A Set per batch. A single file produced ENTRY_CREATE plus one or more ENTRY_MODIFY. Collecting paths in a LinkedHashSet turns that into one unit of work per file per batch.

key.reset() before the slow work. I call it right after reading the events and before parsing anything. That puts the key back in the queue, so events that arrive while I'm busy aren't lost. If you forget reset() altogether, the watcher goes deaf after the first batch (I forced that in the "When it broke" section).

OVERFLOW means rescan. If the operating system's event buffer fills, the JDK delivers one OVERFLOW event and the individual events are gone. The only safe move is to look at the directory again, which scanExisting() does.

awaitStable is the answer to the slow scanner. It reads the file size, sleeps for the settle time, and reads it again. When two reads match, the file has stopped growing. That handled slip-003.slip in the demo. It's a heuristic, and I'll say exactly where it fails in the troubleshooting section.

Move before you count. In handle, the order is parse, then move to processed/, then call the sink. If the move fails, nothing was counted and the next event retries. The other order double-counts on a failed move.

Moving files out of the inbox is the bookkeeping. The inbox only ever holds slips that haven't been dealt with. The watcher registers ENTRY_CREATE and ENTRY_MODIFY only, so the moves out don't generate events for it.

Checkpoint: compile the whole main source set:

javac --release 21 -d out src/main/java/com/example/returns/*.java

No output means it compiled.

Step 5: Run it against a messy morning

Goal: one program that behaves like a scanner: clean drops, a slow write, a bad slip and a stray file.

src/main/java/com/example/returns/Demo.java

package com.example.returns;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Comparator;
import java.util.stream.Stream;

public class Demo {

    public static void main(String[] args) throws Exception {
        Path root = Path.of("work");
        if (Files.exists(root)) {
            try (Stream<Path> walk = Files.walk(root)) {
                walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
            }
        }
        Path inbox = Files.createDirectories(root.resolve("inbox"));
        Catalogue catalogue = new Catalogue();

        try (InboxWatcher watcher = new InboxWatcher(inbox, root.resolve("processed"), root.resolve("rejected"),
                Duration.ofMillis(250), catalogue::recordReturn, msg -> System.out.println("[watcher] " + msg))) {
            watcher.start();
            System.out.println("watching " + inbox.toAbsolutePath());

            // 1. The safe way: write to a temp name, then rename into place.
            dropAtomically(inbox, "slip-001.slip", "isbn=9780134685991\nmember=M-1042\ncondition=good\n");
            dropAtomically(inbox, "slip-002.slip", "isbn=9780596009205\nmember=M-0007\ncondition=damaged\n");

            // 2. A scanner that writes straight to the final name, slowly, in three chunks.
            Path slow = inbox.resolve("slip-003.slip");
            try (var out = Files.newBufferedWriter(slow)) {
                out.write("isbn=9780134685991\n");
                out.flush();
                Thread.sleep(100);
                out.write("member=M-2210\n");
                out.flush();
                Thread.sleep(100);
                out.write("condition=good\n");
            }

            // 3. A bad slip and a file that isn't a slip at all.
            dropAtomically(inbox, "slip-004.slip", "isbn=123\nmember=M-9\n");
            Files.writeString(inbox.resolve("notes.txt"), "front desk reminders\n");

            Thread.sleep(1500);
            System.out.println("catalogue: " + catalogue);
        }
        System.out.println("inbox now:     " + names(inbox));
        System.out.println("processed now: " + names(root.resolve("processed")));
        System.out.println("rejected now:  " + names(root.resolve("rejected")));
    }

    static void dropAtomically(Path dir, String name, String content) throws IOException {
        Path tmp = dir.resolve(name + ".tmp");
        Files.writeString(tmp, content);
        Files.move(tmp, dir.resolve(name), StandardCopyOption.ATOMIC_MOVE);
    }

    static java.util.List<String> names(Path dir) throws IOException {
        try (Stream<Path> s = Files.list(dir)) {
            return s.map(p -> p.getFileName().toString()).sorted().toList();
        }
    }
}

dropAtomically is the writer-side half of the contract: write to name.tmp, then rename to name. The watcher ignores anything that doesn't end in .slip, so it never sees the half-written file. ATOMIC_MOVE means the final name appears complete or not at all.

Drop files in and watch it react

java -cp out com.example.returns.Demo
watching /home/claude/returns/work/inbox
[watcher] processed slip-001.slip
[watcher] processed slip-002.slip
[watcher] processed slip-003.slip
[watcher] rejected slip-004.slip: isbn must be 13 digits: 123
catalogue: returned={9780134685991=2, 9780596009205=1}, damaged=1
inbox now:     [notes.txt]
processed now: [slip-001.slip, slip-002.slip, slip-003.slip]
rejected now:  [slip-004.slip, slip-004.slip.reason]

The whole run took 1.8 seconds, nearly all of it the program's own Thread.sleep(1500). Two copies of 9780134685991 came in (slips 001 and 003), one damaged copy of 9780596009205, notes.txt was ignored, and the bad slip sits in rejected/ with its reason beside it. If you see that, the hard part's done.

If the watching line shows a different folder than you expect, you started the program from a different working directory. work/ is relative.

Twenty-three checks, including a 300-file burst

I couldn't download JUnit in my environment, so the checks are a plain main with a check(name, boolean) helper. Moving to JUnit 5 later is mechanical: each check(...) becomes one @Test with an assertThat, and waitFor becomes Awaitility's await().atMost(...).

src/test/java/com/example/returns/Checks.java

package com.example.returns;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BooleanSupplier;
import java.util.stream.Stream;

public class Checks {

    static int passed;
    static int failed;

    public static void main(String[] args) throws Exception {
        slipParsing();
        goodSlipIsProcessed();
        badSlipIsRejectedWithReason();
        tmpFilesAndOtherFilesAreIgnored();
        existingFilesAreHandledAtStartup();
        slowWriterIsNotParsedHalfWritten();
        burstOfSlipsAllArrive();
        sameSlipTwiceCountsTwice();
        closeStopsTheThread();
        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }

    // ---- ReturnSlip.parse ----
    static void slipParsing() {
        ReturnSlip s = ReturnSlip.parse(List.of("# comment", "ISBN = 9780134685991", "member=M-1", ""));
        check("parse: keys are case-insensitive, comments skipped, condition defaults to good",
              s.equals(new ReturnSlip("9780134685991", "M-1", ReturnSlip.Condition.GOOD)));
        check("parse: short isbn rejected", fails(List.of("isbn=123", "member=M-1"), "13 digits"));
        check("parse: missing member rejected", fails(List.of("isbn=9780134685991"), "missing member"));
        check("parse: bad condition rejected", fails(List.of("isbn=9780134685991", "member=M-1", "condition=torn"), "good or damaged"));
        check("parse: line without = rejected", fails(List.of("isbn 9780134685991"), "key=value"));
    }

    static boolean fails(List<String> lines, String messagePart) {
        try {
            ReturnSlip.parse(lines);
            return false;
        } catch (IllegalArgumentException e) {
            return e.getMessage().contains(messagePart);
        }
    }

    // ---- watcher behaviour ----
    static void goodSlipIsProcessed() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("a.slip", "isbn=9780134685991\nmember=M-1\n");
            check("good slip: reaches the catalogue", waitFor(() -> f.catalogue.totalReturned() == 1));
            check("good slip: moved to processed", waitFor(() -> f.exists(f.processed, "a.slip")));
            check("good slip: gone from inbox", !f.exists(f.inbox, "a.slip"));
        }
    }

    static void badSlipIsRejectedWithReason() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("bad.slip", "isbn=abc\nmember=M-1\n");
            check("bad slip: moved to rejected", waitFor(() -> f.exists(f.rejected, "bad.slip")));
            check("bad slip: reason file written", waitFor(() -> f.exists(f.rejected, "bad.slip.reason")));
            check("bad slip: reason names the problem", Files.readString(f.rejected.resolve("bad.slip.reason")).contains("13 digits"));
            check("bad slip: catalogue untouched", f.catalogue.totalReturned() == 0);
        }
    }

    static void tmpFilesAndOtherFilesAreIgnored() throws Exception {
        try (Fixture f = new Fixture(20)) {
            Files.writeString(f.inbox.resolve("x.slip.tmp"), "isbn=9780134685991\nmember=M-1\n");
            Files.writeString(f.inbox.resolve("notes.txt"), "hello");
            Thread.sleep(300);
            check("ignored: .tmp and .txt stay put, nothing processed",
                  f.exists(f.inbox, "x.slip.tmp") && f.exists(f.inbox, "notes.txt") && f.catalogue.totalReturned() == 0);
            Files.move(f.inbox.resolve("x.slip.tmp"), f.inbox.resolve("x.slip"), StandardCopyOption.ATOMIC_MOVE);
            check("ignored: renaming .tmp to .slip makes it count", waitFor(() -> f.catalogue.totalReturned() == 1));
        }
    }

    static void existingFilesAreHandledAtStartup() throws Exception {
        Path root = Files.createTempDirectory("returns-checks");
        Path inbox = Files.createDirectories(root.resolve("inbox"));
        Files.writeString(inbox.resolve("early1.slip"), "isbn=9780134685991\nmember=M-1\n");
        Files.writeString(inbox.resolve("early2.slip"), "isbn=9780134685991\nmember=M-2\n");
        Catalogue c = new Catalogue();
        try (InboxWatcher w = new InboxWatcher(inbox, root.resolve("p"), root.resolve("r"),
                Duration.ofMillis(20), c::recordReturn, m -> { })) {
            w.start();
            check("startup: slips already in the inbox are processed", waitFor(() -> c.totalReturned() == 2));
        }
        deleteTree(root);
    }

    static void slowWriterIsNotParsedHalfWritten() throws Exception {
        // settle = 250 ms, writer pauses 100 ms between chunks: the watcher should wait for the end.
        try (Fixture f = new Fixture(250)) {
            Path slow = f.inbox.resolve("slow.slip");
            try (var out = Files.newBufferedWriter(slow)) {
                out.write("isbn=9780134685991\n"); out.flush(); Thread.sleep(100);
                out.write("member=M-77\n"); out.flush(); Thread.sleep(100);
                out.write("condition=damaged\n");
            }
            check("slow writer: processed whole, member intact", waitFor(() -> f.catalogue.damagedCount() == 1));
            check("slow writer: nothing rejected", f.count(f.rejected) == 0);
        }
    }

    static void burstOfSlipsAllArrive() throws Exception {
        try (Fixture f = new Fixture(5)) {
            for (int i = 0; i < 300; i++) {
                f.dropAtomically("burst-" + i + ".slip", "isbn=9780134685991\nmember=M-" + i + "\n");
            }
            check("burst: all 300 slips counted exactly once", waitFor(() -> f.catalogue.totalReturned() == 300, 20_000));
            Thread.sleep(300);
            check("burst: still exactly 300 after things settle", f.catalogue.totalReturned() == 300);
            check("burst: inbox empty, 300 files in processed", f.count(f.inbox) == 0 && f.count(f.processed) == 300);
        }
    }

    static void sameSlipTwiceCountsTwice() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("one.slip", "isbn=9780134685991\nmember=M-1\n");
            check("repeat: first copy counted", waitFor(() -> f.catalogue.totalReturned() == 1));
            f.dropAtomically("one.slip", "isbn=9780134685991\nmember=M-1\n");
            check("repeat: same file name dropped again is a new return", waitFor(() -> f.catalogue.totalReturned() == 2));
        }
    }

    static void closeStopsTheThread() throws Exception {
        Fixture f = new Fixture(20);
        long start = System.nanoTime();
        f.close();
        long ms = (System.nanoTime() - start) / 1_000_000;
        boolean alive = Thread.getAllStackTraces().keySet().stream().anyMatch(t -> t.getName().equals("inbox-watcher") && t.isAlive());
        check("close: watcher thread ends (" + ms + " ms)", !alive && ms < 1000);
    }

    // ---- helpers ----
    static final class Fixture implements AutoCloseable {
        final Path root;
        final Path inbox, processed, rejected;
        final Catalogue catalogue = new Catalogue();
        final List<String> log = new CopyOnWriteArrayList<>();
        final InboxWatcher watcher;

        Fixture(long settleMillis) throws IOException {
            root = Files.createTempDirectory("returns-checks");
            inbox = Files.createDirectories(root.resolve("inbox"));
            processed = root.resolve("processed");
            rejected = root.resolve("rejected");
            watcher = new InboxWatcher(inbox, processed, rejected, Duration.ofMillis(settleMillis),
                    catalogue::recordReturn, log::add);
            watcher.start();
        }

        void dropAtomically(String name, String content) throws IOException {
            Path tmp = inbox.resolve(name + ".tmp");
            Files.writeString(tmp, content);
            Files.move(tmp, inbox.resolve(name), StandardCopyOption.ATOMIC_MOVE);
        }

        boolean exists(Path dir, String name) { return Files.exists(dir.resolve(name)); }

        long count(Path dir) throws IOException {
            if (!Files.exists(dir)) return 0;
            try (Stream<Path> s = Files.list(dir)) { return s.count(); }
        }

        @Override
        public void close() throws IOException {
            watcher.close();
            deleteTree(root);
        }
    }

    static void deleteTree(Path root) throws IOException {
        try (Stream<Path> walk = Files.walk(root)) {
            walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
        }
    }

    static boolean waitFor(BooleanSupplier condition) throws InterruptedException {
        return waitFor(condition, 5000);
    }

    static boolean waitFor(BooleanSupplier condition, long millis) throws InterruptedException {
        long deadline = System.currentTimeMillis() + millis;
        while (System.currentTimeMillis() < deadline) {
            if (condition.getAsBoolean()) return true;
            Thread.sleep(10);
        }
        return condition.getAsBoolean();
    }

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else { failed++; System.out.println("FAIL  " + name); }
    }
}

Each watcher check builds its own temporary folder, starts a real InboxWatcher on it, and waits (with a deadline) for the outcome. That's the honest way to test this class. A mock WatchService would only prove the loop calls the methods you told it to.

mkdir -p out-test
javac --release 21 -cp out -d out-test src/test/java/com/example/returns/Checks.java
java -cp out:out-test com.example.returns.Checks

On Windows, separate the class path entries with ; instead of :.

PASS  parse: keys are case-insensitive, comments skipped, condition defaults to good
PASS  parse: short isbn rejected
PASS  parse: missing member rejected
PASS  parse: bad condition rejected
PASS  parse: line without = rejected
PASS  good slip: reaches the catalogue
PASS  good slip: moved to processed
PASS  good slip: gone from inbox
PASS  bad slip: moved to rejected
PASS  bad slip: reason file written
PASS  bad slip: reason names the problem
PASS  bad slip: catalogue untouched
PASS  ignored: .tmp and .txt stay put, nothing processed
PASS  ignored: renaming .tmp to .slip makes it count
PASS  startup: slips already in the inbox are processed
PASS  slow writer: processed whole, member intact
PASS  slow writer: nothing rejected
PASS  burst: all 300 slips counted exactly once
PASS  burst: still exactly 300 after things settle
PASS  burst: inbox empty, 300 files in processed
PASS  repeat: first copy counted
PASS  repeat: same file name dropped again is a new return
PASS  close: watcher thread ends (1 ms)

23 passed, 0 failed

The full run takes about 3 seconds. Two of these checks are the ones I'd keep even if I deleted the rest: the burst (it proves exactly-once under load) and the .tmp rename (it proves the contract with the writer).

What the operating system tells you, and what it doesn't

A WatchService is a queue of signalled keys. You register a directory and get back a WatchKey. When something changes in that directory, the key is signalled and put on the service's queue. take() hands you a signalled key, pollEvents() drains its events, and reset() says "ready for more". Between take() and reset() the key is yours, and the service won't deliver it again.

On Linux, the JDK's implementation (sun.nio.fs.LinuxWatchService, which is what my machine reported) sits on inotify, so events come from the kernel and arrive quickly. That isn't promised everywhere. The Javadoc says the API uses the system's native notification facility where there is one and falls back to something like polling where there isn't, and that timing and ordering depend on the implementation. I haven't tested macOS or Windows. If you ship to either, run the checks above on that machine, not just on yours.

What an event tells you is narrow: a name in this directory was created, modified or (if you ask for ENTRY_DELETE) removed. It doesn't tell you the write is finished, and it doesn't say who wrote it. The count on an event is a hint that the same event repeated; my first experiment showed one ENTRY_MODIFY with count=7 for a single 100 KB writeString.

Registration is per directory and not recursive. I created a subfolder and a file inside it while the parent was registered, and no event arrived. To watch a tree you register each directory yourself, including new ones as their ENTRY_CREATE arrives.

Where to go deeper on the related pieces: the file operations here (Files.move, ATOMIC_MOVE, reading with readAllLines) are covered in how to read and write files with NIO.2, and the "register a listener, get told what happened" shape is the one in how to implement the observer pattern in Java.

Where it broke

I wrote the experiments in a single throwaway file, Experiments.java, that uses the raw API and prints what happens. You don't need it to follow along; each entry below is something you can reproduce with the classes above. One bug in it was mine: I polled a key, forgot to reset() it, and my next three "experiments" returned nothing. That was the missing reset() problem happening to me, which is the first entry below. Everything here is output from my runs.

The watcher goes quiet after the first batch. Cause: reset() wasn't called. The key stays in a signalled state and the service never queues it again. In my run, a second file created while the key was un-reset produced null from poll, and calling reset() released three events:

2. first batch: 1 events, NOT calling reset()
   second batch after no reset: null
   after reset(): 3 events delivered

The same code printed 2 events for the first batch on an earlier run. The grouping depends on timing, so never write code that expects a particular number of events.

java.nio.file.NoSuchFileException: /tmp/exp.../nope from register. The directory doesn't exist. The service won't create it. Create it first with Files.createDirectories.

java.nio.file.NotDirectoryException: /tmp/exp.../c.slip from register. You can only register directories. To watch one file, register its parent and filter by name.

java.nio.file.ClosedWatchServiceException from take(), or from register. The service was closed. In InboxWatcher that's the intended exit: close() closes the service, take() throws, run() catches it and returns. In my check, the thread was gone 1 ms after close().

error: incompatible types: CAP#1 cannot be converted to Path. Cause: event.context() on a WatchEvent<?> returns capture of ?, not Path. Cast it: (Path) event.context(). The WatchEvent<Path> generic is only available if you cast the event first.

error: unreported exception IOException; must be caught or declared to be thrown. newWatchService() throws IOException. That's why the constructor in InboxWatcher declares it.

A slip rejected for missing member, though the file is fine on disk. This is the one that bites in production. I set settle to 30 ms in a separate run and made the scanner pause 200 ms between chunks. The size looked stable during a pause, so the watcher parsed half a file:

[watcher] rejected slow.slip: missing member
catalogue: returned={}, damaged=0
rejected: missing member

The file that ended up in rejected/ was whole by then. The fix isn't a bigger settle; any number is a guess about someone else's program. The fix is the rename contract from Step 5: ask the writers to write name.tmp and rename to name.slip. Keep awaitStable as a seatbelt for writers you don't control.

Files in a subfolder are ignored. Registration isn't recursive (see above). No exception, no event, no log line, which is why I'm listing it here.

What changes on a real server

Make the writer contract explicit. Tell the system that produces files to write *.tmp and rename. If you can't, treat awaitStable as best effort and consider a "quiet period" of several seconds on slow network scanners.

Don't trust the watcher as the only source of truth. Events can be lost (OVERFLOW), a process can be down when a file lands, and network folders may not report changes at all. That's why run() scans the folder on startup. For a real service I'd also rescan every few minutes as a safety net; it's five lines and it turns "lost event" from an incident into a delay.

Think about what "exactly once" means for your side effects. In this code, a crash between the move and the sink call loses a count; a crash before the move re-reads the file. For a catalogue update that must never be lost, make the sink idempotent (keyed by file name plus a hash) and move after it succeeds.

Handle the watcher's own failures. run() logs and exits if IOException escapes. A real service should restart it, or fail its health check so the orchestrator does.

Version notes: nothing here changed between Java 11 and 21 except that you'd write the record as a small class on 8–15. Files.readString/writeString need Java 11. If you need to react to thousands of files per second, this is the wrong tool; look at a queue or a message broker, and see how to use BlockingQueue for producer-consumer pipelines for handing events to worker threads.

When you want proper tests with JUnit 5 and Awaitility instead of my waitFor, the next read is how to write unit tests with JUnit 5 in Java.

The watcher and the demo ran correctly on the first compile. What cost me time was the throwaway experiments file, where my own missing reset() made three results look like API behaviour, and the settle-time trap, which no happy-path check would have caught. Build the slow-writer check early.

Keep these four rules

  • Register directories, not files, and call reset() on every key you take.
  • An event means "this name changed", never "this file is complete". Make writers rename into place and still check size stability.
  • Treat OVERFLOW and startup as rescans, and make processing safe to run twice.
  • Run your checks on the operating system you deploy to; the JDK picks the implementation for you.

What you did: built a thread-owned WatchService loop, handled the create/modify duplicates, covered lost events, proved it with 23 checks, and forced seven failures to see their messages.

WatchService questions, answered

How do I watch a directory for changes in Java? Call FileSystems.getDefault().newWatchService(), register the directory Path with the event kinds you want, then loop on take(), pollEvents() and reset(). Step 3 has the 33-line version that prints events.

Why do I get two ENTRY_MODIFY events for one file? Writing a file is usually several operations, and each can be reported. On my Linux machine a single writeString produced a create plus a modify (with a count of 7 in one run), and touch produced a create and a modify. Collect paths into a Set per batch, as InboxWatcher does.

Does WatchService watch subdirectories? No. I created a file in a subfolder of a registered directory and got no event. Register each subdirectory yourself and add new ones as they appear.

Is WatchService reliable on macOS? I haven't tested macOS. The Javadoc says implementations may fall back to polling where there's no native facility, which affects how soon events arrive and whether very short-lived files are seen. Test on the platform you ship to.

References

how-to-tutorialsjava-iojava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments