☕ Java

WatchService

WatchService is a Java NIO.2 API introduced in Java 7 that monitors file system directories for changes using OS-level event notification rather than polling. Where polling checks file metadata (modification time, size) at intervals and misses changes between polls while wasting CPU, WatchService registers directories with the OS notification subsystem — inotify on Linux, FSEvents/kqueue on macOS, ReadDirectoryChangesW on Windows — and blocks until the OS delivers an event. Events are reported as WatchEvent objects with a kind (ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) and a context (the filename relative to the watched directory). Multiple directories can be watched by a single WatchService. The API is the foundation for build tools (Gradle, Maven daemon file watching), hot configuration reload in servers, IDE file change detection, and incremental compilation pipelines. This entry covers the complete WatchService lifecycle from creation to event processing, all event kinds and their OS-level semantics, overflow handling, the distinction between recursive and non-recursive watching, platform-specific behavior differences, high-volume event handling and debouncing patterns, and the relationship between WatchService and the broader NIO.2 FileSystem provider model.

WatchService Lifecycle — Registration, Event Loop, and Key Management

WatchService is obtained from a FileSystem: FileSystems.getDefault().newWatchService() creates a watcher on the default (OS) file system. WatchService implements Closeable and must be closed when done; close() releases the OS-level notification subscription and causes any thread blocked in take() or poll() to receive a ClosedWatchServiceException. Directories are registered with a WatchService via Path.register(WatchService, WatchEvent.Kind<?>... events). The supported event kinds are StandardWatchEventKinds.ENTRY_CREATE (a file or directory was created in the watched directory), StandardWatchEventKinds.ENTRY_DELETE (a file or directory was deleted from the watched directory), and StandardWatchEventKinds.ENTRY_MODIFY (a file in the watched directory was modified). The registration returns a WatchKey, which represents the ongoing subscription. WatchEvent.Kind<?> objects are typed; ENTRY_CREATE and ENTRY_DELETE have context type Path; ENTRY_MODIFY has context type Path. StandardWatchEventKinds.OVERFLOW is a special event kind that signals events may have been lost because the OS event queue was full — it can occur even if not explicitly registered. WatchKey has two states: valid and cancelled. A key is valid from registration until it is cancelled. cancel() unregisters the directory from the WatchService. A key is also automatically cancelled if the watched directory is deleted. isValid() tests whether the key is still active. After registering, the application waits for events using one of three methods. take() blocks indefinitely until an event is available or the WatchService is closed. poll() returns immediately with a WatchKey if events are pending, or null if none. poll(long timeout, TimeUnit unit) waits up to the timeout. When an event is detected, the WatchKey enters the signalled state and is returned by take() or poll(). The key must be reset() after processing its events to return it to the ready state for subsequent events — a key that is not reset() will never be returned by take()/poll() again, silently missing future events. reset() returns false if the key is no longer valid (the directory was deleted or cancel() was called). The event processing sequence: retrieve the key from take()/poll(); iterate over key.pollEvents() to get all pending events; process each event; call key.reset() to re-queue the key; if reset() returns false, remove the key from tracking and handle the directory-gone condition.
Java
// ── Complete WatchService lifecycle ──────────────────────────────────
Path watchDir = Path.of("/home/user/config");

try (WatchService watcher = FileSystems.getDefault().newWatchService()) {

    // Register directory for create, modify, and delete events:
    WatchKey key = watchDir.register(watcher,
        StandardWatchEventKinds.ENTRY_CREATE,
        StandardWatchEventKinds.ENTRY_MODIFY,
        StandardWatchEventKinds.ENTRY_DELETE
    );

    System.out.println("Watching: " + watchDir);
    System.out.println("Key valid: " + key.isValid());  // true

    // Event loop:
    while (true) {
        WatchKey signalledKey;
        try {
            signalledKey = watcher.take();   // blocks until event available
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        } catch (ClosedWatchServiceException e) {
            System.out.println("WatchService closed");
            break;
        }

        // Process all pending events for this key:
        for (WatchEvent<?> event : signalledKey.pollEvents()) {
            WatchEvent.Kind<?> kind = event.kind();

            // Always check for overflow — can occur even if not registered:
            if (kind == StandardWatchEventKinds.OVERFLOW) {
                System.err.println("Event overflow! Some events may have been lost.");
                rescheduleFullScan();   // do a full directory scan to catch up
                continue;
            }

            // Context is the filename (relative to watched directory):
            @SuppressWarnings("unchecked")
            WatchEvent<Path> pathEvent = (WatchEvent<Path>) event;
            Path fileName = pathEvent.context();
            Path fullPath = watchDir.resolve(fileName);   // make absolute

            if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
                System.out.println("Created:  " + fullPath);
                onFileCreated(fullPath);
            } else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
                System.out.println("Modified: " + fullPath);
                onFileModified(fullPath);
            } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
                System.out.println("Deleted:  " + fullPath);
                onFileDeleted(fullPath);
            }
        }

        // CRITICAL: reset() to receive further events for this key
        boolean stillValid = signalledKey.reset();
        if (!stillValid) {
            System.out.println("Watched directory no longer accessible: " + watchDir);
            break;   // directory was deleted or became inaccessible
        }
    }
}   // watcher.close() called here

// ── Watching multiple directories with one WatchService ──────────────
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    Map<WatchKey, Path> keyToDir = new HashMap<>();

    // Register multiple directories:
    Path[] dirs = {
        Path.of("/etc/myapp"),
        Path.of("/var/myapp/config"),
        Path.of("/home/user/.myapp")
    };

    for (Path dir : dirs) {
        WatchKey k = dir.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY,
            StandardWatchEventKinds.ENTRY_DELETE);
        keyToDir.put(k, dir);
        System.out.println("Watching: " + dir);
    }

    while (true) {
        WatchKey key = watcher.take();
        Path dir = keyToDir.get(key);  // which directory triggered?

        for (WatchEvent<?> event : key.pollEvents()) {
            if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;
            @SuppressWarnings("unchecked")
            Path file = dir.resolve(((WatchEvent<Path>) event).context());
            System.out.printf("[%s] %s: %s%n", event.kind().name(), dir.getFileName(), file);
        }

        if (!key.reset()) {
            keyToDir.remove(key);
            System.out.println("Directory no longer accessible: " + dir);
            if (keyToDir.isEmpty()) break;  // no more directories to watch
        }
    }
}

Event Semantics, Overflow, Platform Differences, and Recursive Watching

WatchService watches only the registered directory, not its subdirectories. This is a key limitation: ENTRY_CREATE fires when a file or directory is created directly in the watched directory, but not when files are created inside newly created subdirectories. Recursive watching — watching an entire directory tree — requires registering each subdirectory individually and registering newly created subdirectories as they appear. This is verbose but correct; a walkFileTree to find all existing subdirectories at startup, followed by recursive registration of new directories as ENTRY_CREATE events arrive for directories, achieves full tree watching. Event semantics differ by platform in important ways. On Linux (inotify), ENTRY_MODIFY fires for each write() system call that modifies a file. A single editor save operation that writes the file in multiple chunks may produce multiple MODIFY events. The inotify event is generated at the kernel level and is very precise — there is no polling and events arrive within microseconds of the OS kernel processing the write. The inotify queue size is configurable via /proc/sys/fs/inotify/max_queued_events; if it overflows, OVERFLOW is delivered. On macOS (FSEvents/kqueue), the behavior is coarser. FSEvents coalesces events and may deliver them with some delay (typically 100ms to 1 second) rather than immediately. A single save that produces 10 MODIFY events on Linux may produce 1 MODIFY event on macOS. The exact coalescing behavior is not specified and can vary with macOS version. For build tools and live-reload servers, this means Linux feels faster than macOS for file watching. On Windows (ReadDirectoryChangesW), the API watches a directory and delivers events to a registered callback. Java maps this to WatchKey/WatchEvent with generally accurate event delivery. Large burst events (moving a directory tree) may cause OVERFLOW if the event buffer fills. The Windows implementation watches all subdirectories by default when using FILE_FLAG_BACKUP_SEMANTICS; Java's WatchService does NOT use this flag and watches only the registered directory. The event.count() method returns how many times a particular event occurred since it was last delivered — this is used for OVERFLOW (event.count() returns the number of events lost) and can be > 1 for MODIFY on platforms that coalesce rapid modifications. For OVERFLOW, count() may return zero if the exact count of lost events is unknown. The WatchEvent.Modifier interface allows platform-specific registration options. On macOS and Linux, com.sun.nio.file.SensitivityWatchEventModifier.HIGH/MEDIUM/LOW controls how frequently the JVM polls for events (because Java's WatchService implementation on some platforms uses polling internally rather than true OS push). HIGH gives the fastest response at the cost of more CPU.
Java
// ── Recursive watching — register all subdirectories ─────────────────
void watchRecursively(Path root) throws IOException, InterruptedException {
    WatchService watcher = FileSystems.getDefault().newWatchService();
    Map<WatchKey, Path> keys = new HashMap<>();

    // Register all existing directories in the tree:
    Files.walkFileTree(root, new SimpleFileVisitor<>() {
        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
                throws IOException {
            registerDir(dir, watcher, keys);
            return FileVisitResult.CONTINUE;
        }
    });

    System.out.println("Watching " + keys.size() + " directories recursively");

    while (true) {
        WatchKey key = watcher.take();
        Path dir = keys.get(key);

        for (WatchEvent<?> event : key.pollEvents()) {
            WatchEvent.Kind<?> kind = event.kind();
            if (kind == StandardWatchEventKinds.OVERFLOW) continue;

            @SuppressWarnings("unchecked")
            Path filename = ((WatchEvent<Path>) event).context();
            Path fullPath = dir.resolve(filename);

            // Register new subdirectories as they are created:
            if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
                if (Files.isDirectory(fullPath, LinkOption.NOFOLLOW_LINKS)) {
                    // Register this new directory and all its children:
                    Files.walkFileTree(fullPath, new SimpleFileVisitor<>() {
                        @Override
                        public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes a)
                                throws IOException {
                            registerDir(d, watcher, keys);
                            return FileVisitResult.CONTINUE;
                        }
                    });
                }
                System.out.println("Created: " + fullPath);
            } else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
                System.out.println("Modified: " + fullPath);
            } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
                System.out.println("Deleted: " + fullPath);
            }
        }

        if (!key.reset()) {
            keys.remove(key);
            System.out.println("Directory removed: " + dir);
            if (keys.isEmpty()) break;
        }
    }
}

void registerDir(Path dir, WatchService watcher, Map<WatchKey, Path> keys)
        throws IOException {
    WatchKey key = dir.register(watcher,
        StandardWatchEventKinds.ENTRY_CREATE,
        StandardWatchEventKinds.ENTRY_MODIFY,
        StandardWatchEventKinds.ENTRY_DELETE);
    keys.put(key, dir);
}

// ── Platform-specific sensitivity modifier ─────────────────────────────
// On macOS/Linux where Java polls internally:
import com.sun.nio.file.SensitivityWatchEventModifier;

Path macDir = Path.of("/Users/user/project");
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    macDir.register(watcher,
        new WatchEvent.Kind<?>[]{
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY,
            StandardWatchEventKinds.ENTRY_DELETE
        },
        SensitivityWatchEventModifier.HIGH  // poll every 2 seconds instead of default
    );
    // Without HIGH: macOS may batch events with up to 10-second delay
}

// ── Overflow handling ─────────────────────────────────────────────────
for (WatchEvent<?> event : key.pollEvents()) {
    if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
        int lostEvents = event.count();   // may be 0 if count unknown
        System.err.printf("OVERFLOW: ~%d events lost — performing full scan%n",
            lostEvents);

        // After overflow: scan the directory to catch up:
        try (Stream<Path> entries = Files.list(watchDir)) {
            entries.forEach(p -> {
                if (!knownFiles.contains(p)) {
                    System.out.println("Missed CREATE: " + p);
                    onFileCreated(p);
                    knownFiles.add(p);
                }
            });
        }
        // Check for deleted files:
        knownFiles.removeIf(p -> {
            if (!Files.exists(p)) {
                System.out.println("Missed DELETE: " + p);
                onFileDeleted(p);
                return true;
            }
            return false;
        });
        continue;
    }
    // ... normal event processing
}

Debouncing, Hot Reload Pattern, and Background Thread Design

A recurring problem with file watching is event storms: a single user action (saving a file, running a build) produces dozens of rapid ENTRY_MODIFY events as the editor or build tool writes the file in multiple chunks. Processing each event immediately causes repeated work — reloading a configuration file 30 times for one save operation. Debouncing suppresses rapid repeated events for the same file, waiting for a quiet period (typically 200-500ms) before processing, ensuring only one reload per logical save. The standard debounce implementation for WatchService uses a map from file path to ScheduledFuture. When an event arrives for a file, any existing scheduled reload for that file is cancelled and a new one is scheduled with the debounce delay. After the quiet period, the scheduled task runs and performs the actual reload. This is the same debounce pattern used in UI programming for text field search. The WatchService event loop must run in a dedicated background thread. The take() call blocks indefinitely; blocking the main thread or a request-handling thread is never acceptable. The background thread should be a daemon thread (so it doesn't prevent JVM shutdown) or should handle cancellation via interrupt (the standard pattern: InterruptedException breaks the take() call, the thread sets its interrupt flag and exits cleanly). Hot configuration reload is the canonical production use case for WatchService. A server application watches its configuration directory; when a configuration file changes, it reloads the configuration without restarting. The reload logic must be atomic — swap the entire configuration object reference atomically using an AtomicReference — so that request-handling threads always see either the old or the new configuration, never a partially-loaded intermediate state. The configuration object should be immutable, which makes it safe to read from many threads without synchronization while the watcher thread prepares the new version.
Java
// ── Debounced hot configuration reload ───────────────────────────────
class ConfigWatcher {
    private final Path configDir;
    private final AtomicReference<Config> current;
    private final ScheduledExecutorService debounceScheduler;
    private final Map<Path, ScheduledFuture<?>> pendingReloads = new ConcurrentHashMap<>();
    private final long debounceDelayMs;
    private volatile Thread watchThread;

    ConfigWatcher(Path configDir, Config initial, long debounceDelayMs) {
        this.configDir = configDir;
        this.current = new AtomicReference<>(initial);
        this.debounceDelayMs = debounceDelayMs;
        this.debounceScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "config-reload");
            t.setDaemon(true);
            return t;
        });
    }

    Config getConfig() { return current.get(); }  // always returns consistent snapshot

    void start() {
        watchThread = new Thread(this::watchLoop, "config-watcher");
        watchThread.setDaemon(true);   // don't block JVM shutdown
        watchThread.start();
    }

    void stop() {
        watchThread.interrupt();       // interrupts the take() call
        debounceScheduler.shutdown();
    }

    private void watchLoop() {
        try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
            configDir.register(watcher,
                StandardWatchEventKinds.ENTRY_CREATE,
                StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_DELETE);

            while (!Thread.currentThread().isInterrupted()) {
                WatchKey key;
                try {
                    key = watcher.take();    // blocks until event
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }

                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;

                    @SuppressWarnings("unchecked")
                    Path file = configDir.resolve(((WatchEvent<Path>) event).context());

                    if (!file.toString().endsWith(".json") &&
                        !file.toString().endsWith(".yaml")) continue;  // only config files

                    scheduleReload(file);
                }

                if (!key.reset()) break;
            }
        } catch (IOException e) {
            System.err.println("WatchService error: " + e.getMessage());
        }
    }

    private void scheduleReload(Path file) {
        // Cancel any existing pending reload for this file:
        ScheduledFuture<?> existing = pendingReloads.get(file);
        if (existing != null) existing.cancel(false);

        // Schedule reload after debounce delay:
        ScheduledFuture<?> future = debounceScheduler.schedule(() -> {
            pendingReloads.remove(file);
            reloadConfig(file);
        }, debounceDelayMs, TimeUnit.MILLISECONDS);

        pendingReloads.put(file, future);
    }

    private void reloadConfig(Path file) {
        try {
            System.out.println("Reloading config: " + file);
            Config newConfig = Config.load(configDir);  // load entire config dir
            current.set(newConfig);   // ATOMIC swap — request threads always see consistent state
            System.out.println("Config reloaded successfully");
        } catch (Exception e) {
            System.err.println("Config reload failed — keeping old config: " + e.getMessage());
            // current.get() still returns old config — no partial state
        }
    }
}

// Usage:
Config initial = Config.load(Path.of("/etc/myapp"));
ConfigWatcher watcher = new ConfigWatcher(
    Path.of("/etc/myapp"), initial, 300   // 300ms debounce
);
watcher.start();

// Request handler thread:
void handleRequest(Request req, Response res) {
    Config config = watcher.getConfig();   // always gets a complete, consistent Config
    res.write(config.processRequest(req));
}

// ── WatchService in a virtual thread (Java 21+) ───────────────────────
// Virtual threads make blocking WatchService operations trivial:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        try (WatchService ws = FileSystems.getDefault().newWatchService()) {
            Path.of("/watch/dir").register(ws,
                StandardWatchEventKinds.ENTRY_MODIFY);
            while (true) {
                WatchKey k = ws.take();   // blocks virtual thread (unmounts carrier)
                k.pollEvents().forEach(e ->
                    System.out.println("Event: " + e.context())
                );
                k.reset();
            }
        } catch (Exception e) { e.printStackTrace(); }
    });
}

Related Topics in Java NIO

NIO Overview
Java NIO (New I/O), introduced in Java 1.4 as the java.nio package, is a collection of APIs that complement and in many cases supersede the original java.io stream model. NIO introduces three foundational abstractions absent from java.io: buffers (typed, fixed-capacity containers for data that support direct memory allocation outside the Java heap), channels (bidirectional, closeable connections to I/O entities that read and write buffers rather than individual bytes), and selectors (multiplexers that let a single thread monitor multiple channels for readiness, enabling non-blocking I/O at scale). Java 7 extended NIO with the java.nio.file package, known as NIO.2 or JSR-203, which provides the Path interface, the Files utility class, the FileSystem abstraction, WatchService for file system event monitoring, and the AsynchronousFileChannel API. Together, NIO and NIO.2 enable four I/O models unavailable in java.io: non-blocking I/O (channel reads and writes return immediately if data is unavailable), asynchronous I/O (I/O operations are submitted and a callback or Future signals completion), memory-mapped I/O (a file is mapped directly into the JVM's virtual address space for zero-copy access), and scatter/gather I/O (reading into or writing from multiple buffers in a single system call). This entry covers the conceptual architecture of NIO, the relationship between buffers, channels, and selectors, the four I/O models, the NIO.2 additions and how they relate to the core NIO abstractions, the difference between blocking and non-blocking channels, and which NIO API to use for which problem.
Path
Path is the central interface in java.nio.file, introduced in Java 7 as part of NIO.2, representing a location in a file system as a sequence of path elements separated by a delimiter. Unlike java.io.File, Path is an interface backed by a pluggable FileSystem provider — the default provider maps to the OS file system, but ZIP archives, in-memory file systems, and remote file systems can provide their own Path implementations. Path is immutable and does not represent an open file or an existing file system entry; it is purely a path string with structured access and manipulation methods. Path provides substantially better semantics than File: path component access, resolution, relativization, normalization, and iteration over path elements are all first-class operations. Combined with the Files utility class, Path enables all file system operations with proper IOException-throwing semantics. This entry covers all Path construction methods, every path manipulation method in depth, path resolution and relativization with edge cases, normalization and toRealPath(), path iteration and watching, conversion to URI and File, comparison semantics, and cross-platform path handling.
Files Class
java.nio.file.Files is a utility class introduced in Java 7 containing over 70 static methods that perform all file system operations on Path objects: reading and writing file content, querying and setting file metadata, creating and deleting files and directories, copying and moving, directory listing and recursive traversal, checking permissions and existence, managing symbolic links, and obtaining channels and streams. Files is the NIO.2 replacement for the operations previously handled by java.io.File's mutation methods (which return boolean on failure) and for the InputStream/OutputStream factory pattern used with java.io streams. Every Files method throws IOException (or a subclass like NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException, or AccessDeniedException) rather than returning boolean, making error handling explicit and unambiguous. This entry covers every major category of Files method in depth, the complete set of StandardOpenOption and StandardCopyOption values, atomic operations and their filesystem-level semantics, bulk read/write convenience methods, the directory traversal API including Files.walk, Files.find, and Files.walkFileTree, permission and attribute manipulation, and the interplay between Files methods and the underlying OS system calls.
Channels
A channel in Java NIO is an open connection to an I/O entity — a file, a socket, a pipe — that can be read from, written to, or both. Channels differ from streams in being bidirectional (a single FileChannel can both read and write), operating exclusively with ByteBuffers rather than individual bytes, supporting seeking and position-based access (FileChannel), and being capable of non-blocking mode (network channels). The channel hierarchy descends from java.nio.channels.Channel at the root; ReadableByteChannel and WritableByteChannel define the reading and writing contracts; ByteChannel combines both; SeekableByteChannel adds position and size access; FileChannel extends SeekableByteChannel with file-specific operations including memory mapping, file locking, and zero-copy transfers. For network I/O, SocketChannel, ServerSocketChannel, and DatagramChannel support both blocking and non-blocking modes and are registered with a Selector for multiplexed event-driven I/O. This entry covers the complete FileChannel API including map(), lock(), transferTo(), and transferFrom(); the blocking and non-blocking network channels; the Selector and SelectionKey model; Pipe channels for inter-thread communication; and the AsynchronousChannel group for callback-based or Future-based I/O.