Java BufferedWriter Data Loss — Flush and Close Pitfalls
An unflushed 8KB BufferedWriter buffer vanishes on JVM crash, losing critical logs.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- BufferedReader and BufferedWriter wrap Reader/Writer to buffer data in memory, reducing system calls from O(n) to O(1) per buffer fill.
- Default buffer size is 8,192 characters, configurable via constructor or Files.newBufferedReader() for UTF-8 safe handling.
- readLine() returns null at EOF, not empty string — a common source of infinite loops in production.
- For log processing, BufferedWriter with periodic flush() ensures monitoring tools see data without closing the stream.
- Performance gain: reading a 10,000-line file is often 10–20x faster than unbuffered reads.
- Biggest mistake: forgetting newLine() after write() — output becomes one continuous line cross-platform.
Imagine you're moving books from one room to another. You could carry one book per trip — that works, but it's exhausting and slow. Or you could grab a box, fill it with 20 books, and make one efficient trip. BufferedReader and BufferedWriter are that box. Instead of reading or writing one character at a time to disk (slow, expensive), they collect a bunch of characters in memory first and do the work in bigger, faster chunks. That's literally it.
Every Java application that reads a config file, processes a CSV, writes a log, or handles any text-based I/O is touching the file system — and the file system is brutally slow compared to RAM. If your code reads characters one at a time from disk, you're making thousands of tiny expensive system calls instead of a few efficient ones. At small scale it doesn't matter. At production scale, it absolutely does. This is the gap between code that works and code that performs.
Why BufferedWriter Can Silently Drop Your Data
BufferedReader and BufferedWriter are I/O wrappers that reduce disk or network system calls by batching data into an internal buffer — typically 8 KB. Instead of writing each character individually (which triggers an expensive OS write), BufferedWriter accumulates data and flushes it in bulk. This turns O(n) system calls into O(n / bufferSize) calls, dramatically improving throughput for sequential reads and writes.
Critically, BufferedWriter does not guarantee data is written to disk when you call write(). Data sits in the buffer until it fills, or until flush() or close() is called. If your application crashes before flush(), buffered data is lost. Similarly, BufferedReader reads ahead into its buffer; if you close it prematurely, unread buffered data is discarded — but more dangerously, if you don't close the underlying stream, resources leak.
Use these wrappers whenever you perform bulk text I/O — reading large files line-by-line, writing logs, or processing streams. The performance gain is substantial: a 100 MB file written without buffering can take 10x longer. But never assume data is persisted until flush() or close() completes. In production, always pair BufferedWriter with explicit flush() in a finally block or use try-with-resources.
close() flushes the buffer, but if an exception occurs before close(), buffered data is silently lost. Always flush() in a finally block or use try-with-resources.flush() before a long-running batch job. A JVM crash during the batch lost the last 8 KB of transactions — about 200 records — with no error logged.flush() or close().Why Buffering Exists — The Cost of Unbuffered I/O
Java's base I/O classes like FileReader and FileWriter are perfectly functional — but they're unbuffered. Every call to read() or write() goes straight to the operating system, which means a context switch: your program pauses, the OS takes over, fetches the data, and hands control back. That round-trip costs time even when reading a single byte.
BufferedReader wraps around any Reader (like FileReader) and maintains an internal character array — a buffer — defaulting to 8,192 characters. It reads a big chunk from the underlying source all at once, stores it in that array, and then serves your read() calls from memory. Same principle applies to BufferedWriter: characters accumulate in the buffer and only flush to disk in large batches.
The real-world difference is dramatic. Reading a 10,000-line file with an unbuffered FileReader makes 10,000+ system calls. Wrapping it in a BufferedReader reduces that to a handful. For write-heavy operations like logging or generating reports, BufferedWriter can be the difference between a process that finishes in milliseconds versus seconds.
This is also why you'll see BufferedReader and BufferedWriter in virtually every production Java codebase that touches text files. It's not optional best practice — it's standard practice.
Scanner vs BufferedReader: When to Use Which
Java provides two primary tools for reading text input: Scanner and BufferedReader. Both read characters from a source, but they serve different purposes and have distinct strengths. Choosing the wrong one can lead to performance problems or unnecessarily verbose code.
Scanner is designed for parsing — it can split input into tokens, match patterns with regular expressions, and convert tokens to primitive types (nextInt(), nextDouble(), nextBoolean()). It's ideal for interactive input (System.in), configuration files, or any scenario where you need to extract structured data from a stream. However, Scanner is not buffered by default in terms of large file reads — it uses a 1KB internal buffer — and it has a significant performance overhead due to parsing logic and the use of regular expressions. For line-by-line reading of large files, Scanner can be 2-5x slower than BufferedReader.
BufferedReader is built for pure reading — it provides readLine() and read(char[], int, int) methods that are highly efficient because they bypass parsing entirely. When you only need to read lines and process them yourself, BufferedReader is the faster choice. It also allows you to wrap any Reader, making it compatible with character-stream sources. Its buffer is much larger by default (8KB), leading to fewer system calls.
Here's a comparison table to help decide:
| Feature | Scanner | BufferedReader |
|---|---|---|
| Primary use | Parsing tokens and primitive types | Reading text efficiently (line-by-line) |
| Performance | Slower for large files due to parsing overhead | Faster; large default buffer (8KB) |
| Built-in parsing | Yes — nextInt(), nextDouble(), etc. | No — must parse manually (Integer.parseInt()) |
| Delimiter control | Customizable delimiter (default whitespace) | Fixed line-based (readLine()) |
| Error handling | InputMismatchException for type mismatch | No built-in parsing exceptions |
| Suitable for | User input, config files, small files | Large text files, logs, CSV processing |
| Thread safety | Not thread-safe | Not thread-safe |
| Buffer size | 1,024 characters (internal) | 8,192 characters (configurable) |
In practice, if you need to parse structured input (e.g., integers separated by spaces), use Scanner. If you need high-performance line-by-line reading with manual parsing (e.g., splitting a CSV), use BufferedReader. For most file-processing tasks in production, BufferedReader is the better choice because it gives you control over parsing and is significantly faster.
A common anti-pattern is using Scanner to read a 100MB log file line by line with nextLine(). While it works, it's 3-5x slower than BufferedReader.readLine() and consumes more memory due to Scanner's internal caching. Always benchmark for large files.
If you need both parsing and performance, wrap a BufferedReader in a Scanner: new Scanner(new BufferedReader(new FileReader(file))). This gives you the speed of buffered I/O with the convenience of Scanner's parsing methods.
Reading Text Files the Right Way — Line by Line with BufferedReader
The single most powerful feature of BufferedReader over raw FileReader is the readLine() method. It reads an entire line of text, strips the line terminator, and returns it as a String. When the file ends, it returns null — that's your loop exit signal.
This matters for a practical reason: most text-based data — logs, CSVs, config files, JSON-per-line formats — is structured around lines. readLine() matches how humans and programs actually think about that data.
The modern way to construct a BufferedReader for a file is through Files.newBufferedReader(path), introduced in Java 7 with NIO.2. It handles the charset correctly (defaulting to UTF-8), is more concise than chaining constructors, and integrates naturally with the Path API. For legacy code or when you genuinely need to wrap an existing stream, the constructor-chaining approach (new BufferedReader(new FileReader(file))) is still perfectly valid.
Always use try-with-resources. If you manually call close() and an exception fires before you reach it, the file handle leaks. On servers that process thousands of requests, leaked file handles accumulate into a dreaded 'Too many open files' OS error that brings the whole application down.
Files.newBufferedReader() for UTF-8 by default; don't rely on platform charset.Reader/Writer Method Reference Table
Understanding the core methods of Reader, Writer, and their buffered counterparts is essential for using them correctly. Below is a reference table of the most important methods in the Reader and Writer hierarchy. Use this as a quick lookup when designing your I/O logic.
| Method | Class | Description | Returns | Common Pitfall |
|---|---|---|---|---|
| Reader | Reads a single character | int (0-65535) or -1 at EOF | Forgetting to cast to char; returning -1 on EOF |
read(char[] cbuf, int off, int len) | Reader | Reads characters into an array | int (number of chars read) or -1 | Not checking return value; assuming full buffer fill |
readLine() | BufferedReader | Reads a line of text (null at EOF) | String or null | Checking line.isEmpty() instead of line != null |
skip(long n) | Reader | Skips n characters | long (actual skipped) | Skipping more than available; not checking return |
| Reader/Writer | Closes the stream and releases resources | void | Not using try-with-resources |
write(int c) | Writer | Writes a single character | void | Writing an int without casting — produces garbage |
write(String str, int off, int len) | Writer | Writes a portion of a string | void | Off-by-one errors in len parameter |
write(char[] cbuf, int off, int len) | Writer | Writes a portion of a char array | void | ArrayIndexOutOfBounds if off+len > length |
newLine() | BufferedWriter | Writes platform-specific line separator | void | Hardcoding `. |
| BufferedWriter | Forces buffered data to be written | void | Not calling when real-time visibility needed |
append(CharSequence csq) | Writer | Appends a character sequence | Writer | Forgetting that it returns the writer for chaining |
These methods cover 90% of what you'll use in daily file I/O. Key notes:
returns an int, not a char. You must cast it to char if you need the character. The -1 return indicates end-of-stream.read()readLine()is exclusive to BufferedReader. It returns null at EOF — never an empty string.newLine()is better than hardcoding line separators because it ensures cross-platform compatibility.is critical when multiple processes or monitoring tools need to see data immediately.flush()
For bulk reading, prefer read(char[], off, len) over read() to reduce system calls. For writing, batch writes and call flush() sparingly to balance performance with visibility.
flush() for visibility.Writing Text Files Correctly — BufferedWriter in Practice
BufferedWriter's job is to collect your write() calls in memory and flush them to disk in one efficient batch. Its three most important methods are write(String text), newLine(), and flush().
newLine() is the one you shouldn't skip. Writing a hardcoded works on Linux and macOS, but Windows uses \r as its line terminator. newLine() uses System.lineSeparator() under the hood, making your output correct on every platform. If your application generates files that users open in Notepad, this matters.
flush() forces everything in the buffer out to disk right now, without closing the writer. You'll need this when writing to a file that another process is watching in real time — like a log file that a monitoring tool is tailing. Without flush(), data can sit silently in the buffer while the other process sees nothing.
close() both flushes the buffer and releases the file handle. With try-with-resources, close() is called automatically. But here's the subtlety: if you're writing a long-running process and want to ensure data is on disk periodically without closing the writer, you must call flush() manually at the right checkpoints.
printf() and println() convenience. Just remember that PrintWriter silently swallows IOExceptions — check checkError() if reliability matters, or stick with BufferedWriter directly for error-critical writes.writer.write(line + "\n") instead of writer.newLine() — the log file looked fine on Linux but was unreadable on Windows.flush() on critical writes — a crash after write() but before flush() loses data.close() for final flush.Copying Files and Chaining Readers — A Complete Real-World Pattern
One of the most instructive exercises with BufferedReader and BufferedWriter is implementing a text file copy — it forces you to handle charsets, line endings, and proper resource management all at once.
But the real value here is understanding the decorator pattern these classes use. BufferedReader doesn't replace FileReader — it wraps it. This means you can buffer any Reader: an InputStreamReader decoding network data, a StringReader for testing, a PipedReader for thread communication. The buffering layer is completely agnostic about where the data comes from. Same for BufferedWriter. This composability is intentional Java I/O design.
The example below shows a file copy utility that also tracks statistics — a pattern you'd genuinely find in ETL pipelines, log rotation utilities, and build tools. It also shows a common real-world requirement: transforming content during the copy, in this case normalising inconsistent whitespace.
Character Encoding and Charset Handling with BufferedReader and BufferedWriter
One of the most overlooked aspects of buffered I/O is charset handling. Files.newBufferedReader(path) uses UTF-8 by default — a safe, modern choice. But new BufferedReader(new FileReader(file)) uses the platform's default charset, which can be Windows-1252 on one system and UTF-8 on another. This mismatch causes data corruption when files are moved between environments.
Use Files.newBufferedReader(path, charset) or Files.newBufferedWriter(path, charset) to be explicit. Specify StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1, or a Charset.forName() as needed. This is critical when your application processes files from multiple sources (e.g., legacy systems sending ISO-8859-1, modern APIs sending UTF-8).
Another pitfall: BufferedReader reads characters, not bytes. If you're working with binary data or need byte-level operations (e.g., reading image headers, custom protocols), you need InputStream + BufferedInputStream, not Reader. Mixing Reader/Writer with byte streams causes data loss or corruption.
The example below demonstrates reading a file with explicit charset and writing with a different one — a common data migration scenario.
Files.newBufferedReader() defaulted to UTF-8 on dev but Windows-1252 on a legacy server. 400 invoices had corrupted names.Flushing: The Silent Nightmare That Corrupts Logs
You've written your data. You called close(). Everything's fine, right? Wrong. If your application crashes between the last write() and the close(), whatever was sitting in that 8 KB buffer evaporates. No exception. No stack trace. Just missing data.
Buffered streams exist to batch writes, but that buffering is a liability if you don't control when the data actually hits disk. The method forces the buffer contents to the underlying stream immediately. Call it after every logical chunk of work — not just at the end.flush()
Every senior dev has debugged a partial log file or a corrupted config file because someone assumed would magically save them. It won't if the JVM dies first. Treat close() like a seatbelt: you don't skip it because the drive is short.flush()
close() to flush. In long-running processes, flush() periodically or after each transaction. Your logs will thank you.close(). Assume your JVM can die at any moment.Buffer Size: Why 8192 Characters Isn't Magic
The default buffer size is 8192 characters. That's 16 KB in most encodings. It's a reasonable default for general text processing, but it's not optimal for everything. If you're writing large files sequentially, a bigger buffer reduces system calls. If you're writing tiny log entries, a smaller buffer might save memory.
You can pass a custom size to the constructor: new BufferedWriter(new FileWriter("data.txt"), 65536). That's 64 KB. For big sequential writes, you'll see noticeable throughput gains because you're hammering the OS less.
Don't blindly use 8192. Profile your workload. The best buffer size is the one that matches your I/O pattern. Rule of thumb: match the buffer to the block size of your filesystem (usually 4 KB) or the typical write chunk size.
One more thing: BufferedReader also accepts a buffer size. If you're reading large files line-by-line, a larger buffer reduces disk seeks. But don't go overboard — 8 MB buffers waste memory for no gain on modern SSDs.
Big Picture: How BufferedReader and BufferedWriter Are Related
BufferedReader and BufferedWriter are sibling decorators in Java's I/O hierarchy, both extending the abstract Reader and Writer classes respectively. They share a common purpose: wrapping a raw, character-based stream to add an internal buffer that minimizes expensive I/O operations. BufferedReader reads chunks from an underlying reader into memory, letting you call readLine() without touching the disk each time. BufferedWriter collects written characters into a buffer before flushing them to the underlying writer in one batch. Together, they form a matched pair for efficient text processing — one handles input, the other output. In practice, you often chain them end-to-end: read from a BufferedReader, process the data, then write through a BufferedWriter. This symmetry reduces system calls on both sides of a data pipeline. Understanding this relationship helps you design I/O code that performs predictably, because the buffering logic on each side follows the same principle: trade memory for speed, but flush carefully to avoid data loss.
Big Picture: The Decorator Pattern in Practice
Java's Reader and Writer classes are built around the Decorator pattern, where you wrap one stream inside another to add functionality. BufferedReader and BufferedWriter are the most common decorators for text I/O. You never use them alone; they always wrap an underlying reader or writer like FileReader, InputStreamReader, or FileWriter. This layering is why you see code like new BufferedReader(new FileReader("file.txt")) — the FileReader handles the file, and BufferedReader adds buffering on top. The same applies to writing. This design lets you mix and match: you can buffer a network stream, a file, or even a string reader. The key insight is that the buffer layer is transparent — your code reads and writes normally, but performance changes dramatically. Understanding this pattern prevents you from needlessly buffering an already-buffered stream (like wrapping a ByteArrayInputStream in BufferedReader, which wastes memory). Always ask: what is the underlying resource? Then add exactly one buffering layer for that resource.
buffered.read() fetches a chunk from raw and caches it, so each System.out.print hits memory, not disk. Without the decorator, every call to raw.read() would be a disk read.The Silent Data Loss in Buffered Logging
close(). The buffered data never flushed to disk. The last ~7KB of log data sat in the 8KB buffer and evaporated on JVM crash.flush() on critical writers as a safety net.- try-with-resources is non-negotiable — it guarantees
close()releases the file handle and flushes the buffer even on exceptions. - For high-importance writes (error logs, transaction records), call
flush()after every write and consider using an explicit ShutdownHook. - Never assume a crash will flush buffers. The OS closes file handles, but the Java buffer is in user space — gone when the process dies.
flush() was called before the program exited. Use strace -e trace=write -p <pid> to see if data is being sent to the OS. Add a ShutdownHook to flush critical writers.close(). If the process was killed with SIGKILL (kill -9), even the OS buffer can be lost — use synchronous writes.flush() for real-time monitoring. Use lsof -p <pid> to check if the file descriptor is still open. Check buffer size; smaller buffers flush more often but increase system calls.while ((line = reader.readLine()) != null). If you mistakenly check line.isEmpty(), you'll get an infinite loop at EOF. Use jstack <pid> to see stuck threads.strace -e trace=write -p <pid> 2>&1 | head -20lsof -p <pid> | grep <logfile>writer.flush() after critical writes. Wrap in try-with-resources.| File | Command / Code | Purpose |
|---|---|---|
| BufferedVsUnbufferedDemo.java | public class BufferedVsUnbufferedDemo { | Why Buffering Exists |
| ScannerVsBufferedReaderBenchmark.java | public class ScannerVsBufferedReaderBenchmark { | Scanner vs BufferedReader |
| CsvFileProcessor.java | public class CsvFileProcessor { | Reading Text Files the Right Way |
| MethodReferenceDemo.java | public class MethodReferenceDemo { | Reader/Writer Method Reference Table |
| ApplicationLogWriter.java | public class ApplicationLogWriter { | Writing Text Files Correctly |
| TextFileCopyUtility.java | public class TextFileCopyUtility { | Copying Files and Chaining Readers |
| CharsetConversionUtility.java | public class CharsetConversionUtility { | Character Encoding and Charset Handling with BufferedReader |
| FlushOnCrash.java | public class FlushOnCrash { | Flushing |
| CustomBufferSize.java | public class CustomBufferSize { | Buffer Size |
| FileCopyBuffered.java | public class FileCopyBuffered { | Big Picture |
| BufferedDecorator.java | public class BufferedDecorator { | Big Picture |
Key takeaways
System.lineSeparator() and keeps your output correct across Windows, Linux, and macOS.Interview Questions on This Topic
Why would you use BufferedReader instead of FileReader directly, and what exactly happens internally that makes it faster?
read() — each call involves a context switch from user space to kernel space, which is expensive. BufferedReader wraps FileReader and reads a large block (default 8,192 characters) into a memory buffer in a single system call. Subsequent read() calls are served from that buffer without touching the OS. The buffer is refilled only when exhausted. This reduces system calls from O(n) to O(n/bufferSize), typically yielding 10-20x speedup. Additionally, BufferedReader adds the readLine() method which is not available in FileReader.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Java I/O. Mark it forged?
10 min read · try the examples if you haven't