@RestController
@RequestMapping("/api/transactions")
public class TransactionExportController {
private final CsvExportService exportService;
public TransactionExportController(CsvExportService exportService) {
this.exportService = exportService;
}
@GetMapping(value = "/export", produces = "text/csv")
public ResponseEntity<StreamingResponseBody> export(
@RequestParam("accountId") long accountId) {
String filename = "transactions-" + accountId + ".csv";
StreamingResponseBody body = outputStream ->
exportService.writeTo(accountId, outputStream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(body);
}
}
@Service
public class CsvExportService {
private static final String HEADER = "id,posted_at,description,amount\n";
private final TransactionRepository repository;
public CsvExportService(TransactionRepository repository) {
this.repository = repository;
}
public void writeTo(long accountId, OutputStream out) throws IOException {
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(out, StandardCharsets.UTF_8));
writer.write(HEADER);
AtomicInteger count = new AtomicInteger();
repository.streamAll(accountId, row -> {
try {
writer.write(row.id() + ","
+ row.postedAt() + ","
+ escape(row.description()) + ","
+ row.amount() + "\n");
if (count.incrementAndGet() % 500 == 0) {
writer.flush();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
writer.flush();
}
private String escape(String field) {
if (field == null) {
return "";
}
if (field.contains(",") || field.contains("\"") || field.contains("\n")) {
return "\"" + field.replace("\"", "\"\"") + "\"";
}
return field;
}
}
@Repository
public class TransactionRepository {
private final JdbcTemplate jdbcTemplate;
public TransactionRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void streamAll(long accountId, Consumer<TransactionRow> consumer) {
String sql = "SELECT id, posted_at, description, amount "
+ "FROM transactions WHERE account_id = ? ORDER BY posted_at";
jdbcTemplate.query(
con -> {
PreparedStatement ps = con.prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY);
ps.setFetchSize(1000); // server-side cursor, avoid loading all rows
ps.setLong(1, accountId);
return ps;
},
(RowCallbackHandler) rs -> consumer.accept(new TransactionRow(
rs.getLong("id"),
rs.getTimestamp("posted_at").toInstant().toString(),
rs.getString("description"),
rs.getBigDecimal("amount").toPlainString())));
}
public record TransactionRow(long id, String postedAt,
String description, String amount) {
}
}
This snippet shows how a Spring Boot application exports a potentially huge dataset as CSV without ever holding the whole result set in memory. The core idea is to keep the export lazy end-to-end: rows are pulled from the database with a streaming JDBC cursor, formatted incrementally, and written straight to the servlet output stream as they arrive. Because Spring MVC releases the request-handling thread while the body is produced, the container can serve other requests instead of blocking, and the response is flushed to the client in chunks rather than buffered.
In TransactionExportController, the endpoint returns a StreamingResponseBody rather than a List or a byte array. The Content-Disposition and Content-Type headers are set so the browser treats the payload as a downloadable CSV file. Returning ResponseEntity<StreamingResponseBody> lets the controller set status and headers up front while deferring body production to the callback, which Spring invokes on a separate thread once the response has been committed.
The actual writing lives in CsvExportService. The writeTo method wraps the OutputStream in a BufferedWriter and delegates row streaming to the repository. Each TransactionRow is escaped and written, and the writer is flushed periodically so bytes reach the socket without accumulating. Escaping in escape handles the classic CSV pitfalls — embedded commas, quotes, and newlines — by quoting fields and doubling internal quotes.
In TransactionRepository, streamAll uses Spring's JdbcTemplate with a forward-only, read-only statement configured with a JDBC fetchSize so the driver keeps a server-side cursor open instead of materializing every row. A RowCallbackHandler pushes each row through a Consumer, meaning no intermediate collection is built.
The main trade-off is that the response is committed early, so errors mid-stream cannot change the HTTP status — the download simply truncates. The database connection and cursor stay open for the whole download, so slow clients tie up a connection; a bounded pool and sensible timeouts matter. This pattern is the right tool when result sets are large or unbounded and latency-to-first-byte and steady memory use matter more than transactional neatness.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
Share this code
Here's the card — post it anywhere.