@RestController
@RequestMapping("/api/exports")
public class ExportController {
private final CsvStreamWriter csvStreamWriter;
public ExportController(CsvStreamWriter csvStreamWriter) {
this.csvStreamWriter = csvStreamWriter;
}
@GetMapping(value = "/users", produces = "text/csv")
public ResponseEntity<StreamingResponseBody> exportUsers() {
StreamingResponseBody body = outputStream -> csvStreamWriter.writeUsers(outputStream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8")
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"users.csv\"")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(body);
}
}
@Component
public class CsvStreamWriter {
private static final int FLUSH_EVERY = 500;
private final UserExportRepository repository;
public CsvStreamWriter(UserExportRepository repository) {
this.repository = repository;
}
public void writeUsers(OutputStream out) throws IOException {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8), 8192);
writer.write("id,email,full_name,created_at\n");
AtomicInteger count = new AtomicInteger();
try (Stream<UserRow> rows = repository.streamAll()) {
rows.forEach(row -> writeRow(writer, row, count));
} catch (UncheckedIOException e) {
throw e.getCause();
}
writer.flush();
}
private void writeRow(BufferedWriter writer, UserRow row, AtomicInteger count) {
try {
writer.write(row.id() + "," + escape(row.email()) + ","
+ escape(row.fullName()) + "," + row.createdAt() + "\n");
if (count.incrementAndGet() % FLUSH_EVERY == 0) {
writer.flush();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private String escape(String value) {
if (value == null) {
return "";
}
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
}
@Repository
public class UserExportRepository {
private final JdbcTemplate jdbcTemplate;
public UserExportRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
// Server-side cursors require autocommit off; fetchSize is applied per statement below.
this.jdbcTemplate.setFetchSize(1000);
}
public Stream<UserRow> streamAll() {
String sql = "SELECT id, email, full_name, created_at FROM users ORDER BY id";
return jdbcTemplate.queryForStream(sql, (rs, rowNum) -> new UserRow(
rs.getLong("id"),
rs.getString("email"),
rs.getString("full_name"),
rs.getTimestamp("created_at").toInstant().toString()));
}
}
public record UserRow(
long id,
String email,
String fullName,
String createdAt) {
}
Exporting a large table as CSV over HTTP is a classic memory trap: building the whole file in a StringBuilder or a List<Row> before writing it forces the entire dataset into heap, which either OOMs the server or spikes GC pauses under concurrent requests. The fix is to treat the export as a pipeline where rows flow from the database straight to the socket, keeping only a small window in memory at any moment.
In ExportController, the endpoint returns a StreamingResponseBody instead of a materialized body. Spring hands that lambda the raw OutputStream of the servlet response and invokes it on a separate task executor, so the request thread is released while bytes are flushed incrementally. The Content-Type and Content-Disposition headers are set up front, so the browser begins downloading before the query finishes. Crucially, the controller never holds the full result — it delegates writing to CsvStreamWriter.
CsvStreamWriter owns the actual serialization. It wraps the OutputStream in a BufferedWriter and asks UserExportRepository for a Stream<UserRow>. Because the stream is lazy and backed by a forward-only cursor, each forEach iteration writes one CSV line and periodically calls flush(), bounding memory to the buffer size regardless of row count. The try (Stream ...) block matters: closing the stream releases the JDBC ResultSet and connection.
UserExportRepository is where the memory discipline is enforced. It uses Spring's JdbcTemplate with queryForStream, sets a small setFetchSize hint, and disables auto-commit so PostgreSQL streams rows via a server-side cursor rather than buffering them all client-side — a common pitfall, since the default driver behavior loads everything.
The trade-off is that the HTTP response is committed early, so errors mid-stream can't be turned into a clean 500 with a JSON body; the download simply truncates. Callers should treat a short or malformed file as failure. This pattern is the right reach whenever the export size is unbounded or user-controlled, and it scales to millions of rows on a fixed heap.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import Combine
import Foundation
class SearchViewModel: ObservableObject {
@Published var searchQuery = ""
@Published var results: [SearchResult] = []
Combine operators for data transformation
Share this code
Here's the card — post it anywhere.