Queue draining starts with a small decision that changes how a service writes data. Instead of sending every item to the database as soon as the item arrives, the service places it into a buffer for a short time, then writes a group of items in one batch. That gives the application a way to trade a little waiting time for fewer database calls. The goal is not to hide slow writes or treat memory like permanent storage. Instead, the goal is to control write flow so request traffic, scheduled draining, batch size, and database capacity all stay within limits the service can handle.
Item Buffering Before a Batch
Before we can write items in batches, the service needs a short holding area for incoming data. That holding area is the buffer. It gives the request flow a place to hand off accepted items without making every request wait for a database write. The buffer is temporary memory, not final storage, so we should treat it as a short stop between receiving the item and sending it to the later batch writer.
The Buffer Boundary
Incoming data crosses an important boundary when we move it from the request flow into the buffer. Before that point, the item belongs to the caller’s request, message, or service method. After that point, the item belongs to the later batch write flow. That change is why the queued value should be plain application data with the fields the writer needs. Spring Boot code can receive the item through a controller, listener, scheduled import, or service method. We should not place the original HTTP request, an active transaction, an open stream, or any object tied to the current call into the queue. The later writer can run after the original thread is finished, so the queued value needs to make sense by itself.
package com.alex.demo.events;
import java.time.Instant;
public record ProductEvent(
long productId,
String action,
Instant occurredAt
) {
}The record keeps the queued value small. It carries the product id, the action that happened, and the time the event occurred. That gives the later writer enough information to create a row without reaching back into the web layer or rebuilding request state.
We can create the event at the point where the service has already accepted the input. That keeps the handoff easy to follow because the buffer receives a value that is already ready for later storage.
package com.alex.demo.products;
import java.time.Clock;
import java.time.Instant;
import org.springframework.stereotype.Service;
import com.alex.demo.events.ProductEvent;
import com.alex.demo.events.ProductEventBuffer;
@Service
public class ProductEventService {
private final ProductEventBuffer productEventBuffer;
private final Clock clock;
public ProductEventService(
ProductEventBuffer productEventBuffer,
Clock clock
) {
this.productEventBuffer = productEventBuffer;
this.clock = clock;
}
public void recordViewed(long productId) {
ProductEvent event = new ProductEvent(
productId,
"VIEWED",
Instant.now(clock)
);
productEventBuffer.add(event);
}
}We build the ProductEvent before calling the buffer, so the queued item is not tied to a controller method or a database transaction from the request path. The receiving side accepts the item first, while the drain path later deals with storage.
A BlockingQueue fits this handoff because request threads can add items while the later batch flow removes them. LinkedBlockingQueue also lets us set a fixed capacity, which helps keep memory use within a known limit.
package com.alex.demo.events;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.springframework.stereotype.Component;
@Component
public class ProductEventBuffer {
private static final int MAX_QUEUE_SIZE = 10_000;
private final BlockingQueue<ProductEvent> queue =
new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);
public void add(ProductEvent event) {
boolean accepted = queue.offer(event);
if (!accepted) {
throw new ProductEventBufferFullException();
}
}
public int queuedEvents() {
return queue.size();
}
}This class owns the queue and keeps outside code from changing it directly. Other classes can add accepted items and read the current count, but they do not get direct access to remove values or alter the queue contents.
Bounded Capacity
Capacity gives the buffer a hard limit. Without a limit, the queue can keep accepting items while the database is slow, unavailable, or already backed up. That hides the pressure for a while, but the cost moves into heap memory. The service should have a point where it stops accepting more in-memory write data and gives the caller or listener a defined response.
The sample uses 10_000 as the maximum queue size. That value should come from the size of each item, the memory available to the JVM, the normal traffic rate, and how long the product can tolerate temporary waiting. Small records with ids, action names, timestamps, and short values are easier to plan around than large objects with nested data.
The offer method is useful because it tries to add the item without waiting forever. If the queue is full, it returns false, and the service can react:
package com.alex.demo.events;
public class ProductEventBufferFullException extends RuntimeException {
public ProductEventBufferFullException() {
super("Product event buffer is full");
}
}The custom exception turns a full buffer into an application decision instead of a hidden memory problem. From an API endpoint, that could become a temporary failure response. From a message listener, the service could leave the message unacknowledged so the broker can redeliver it later. Low-value telemetry could count the rejection and move on. The buffer should not make every product decision by itself, but it should report that it cannot accept more items.
Durability is separate from capacity. With a bounded in-memory queue, the JVM is protected from unlimited growth, but queued items still disappear if the process exits before they are written. That can be acceptable for derived analytics, refresh hints, or events that can be recreated. It is not acceptable for payments, orders, account changes, or data that must survive a restart.
We can also expose a narrow status method without giving other classes direct queue access. That lets later monitoring code read the count while the buffer remains the owner of its internal state.
package com.alex.demo.events;
import org.springframework.stereotype.Component;
@Component
public class ProductEventBufferStatus {
private final ProductEventBuffer productEventBuffer;
public ProductEventBufferStatus(ProductEventBuffer productEventBuffer) {
this.productEventBuffer = productEventBuffer;
}
public int queuedEvents() {
return productEventBuffer.queuedEvents();
}
}Outside code reads a safe number instead of reaching into the queue. That keeps the boundary tight, which matters because the queue is the service’s handoff point between accepted items and later batch storage.
Queue Draining on Size or Time
After items enter the buffer, the service needs rules for removing them and sending them to storage. The drain logic bridges memory and the database writer. It decides how large a write group can get, how long partial groups can wait, and how the service reacts when a write succeeds or fails. We keep the queue as the handoff point, then let size, time, and write results control the flow.
Threshold Flushes
Traffic can rise quickly, so the drain logic needs a size-based trigger. The threshold says that when the queue reaches a target count, the service should stop waiting and send a group to the writer. With a threshold of 500, the service can write up to 500 events in one batch call instead of sending a separate database call for every event.
We can keep the threshold check near the add flow because that is the moment a new item enters the queue. The check does not turn the new item into its own separate database write. It only asks the buffer to drain if enough items have collected:
package com.alex.demo.events;
public void add(ProductEvent event) {
boolean accepted = queue.offer(event);
if (!accepted) {
throw new ProductEventBufferFullException();
}
if (queue.size() >= MAX_BATCH_SIZE) {
drain();
}
}After the item is accepted, queue.size() gives the service a practical reason to try a threshold flush. In this synchronous version, the request that crosses the threshold can also spend time running the drain. That is different from writing every item one by one, but it still means the threshold caller may do batch work. In highly concurrent code, the count can change right after we read it, and that is fine for this case. The threshold is a trigger, not a data integrity rule. The actual item removal happens through the queue call that moves values into a local batch.
The threshold is also not a required batch size. If the maximum batch size is 500, the writer can still receive fewer items when a timer-based drain runs. During heavier traffic, the writer can receive several groups of 500 as the drain loop catches up. The threshold tells the service when to start draining, while the maximum batch size limits how much one write call should carry.
The drain loop can remove items in chunks, which prevents the service from sending one huge database call after a traffic spike. The local list becomes the handoff between the queue and the writer, and that keeps the database batch size within a known limit:
package com.alex.demo.events;
import java.util.ArrayList;
import java.util.List;
private void drainAvailableBatches() {
List<ProductEvent> batch = new ArrayList<>(MAX_BATCH_SIZE);
do {
batch.clear();
queue.drainTo(batch, MAX_BATCH_SIZE);
if (!batch.isEmpty()) {
writeBatch(batch);
}
} while (queue.size() >= MAX_BATCH_SIZE);
}We limit every batch passed toward the writer with MAX_BATCH_SIZE, so the writer does not receive an unlimited amount of queued data at the same time. The service can still drain more than one batch if the queue has built up, but each database transaction stays within the batch limit.
Batch size changes throughput and waiting time at the same time. Larger groups reduce database round trips, which can help during heavy traffic. Smaller groups leave memory sooner and reduce the size of each write, but they create more database calls. We should choose the value from measurements such as queue depth, flush duration, connection pool activity, database CPU, and transaction time.
Timer Flushes
Quiet traffic needs a different trigger because the queue may not reach the size threshold for a while. Timer-based draining gives partial batches a maximum waiting period. If only 20 events arrive and the threshold is 500, the timer still gives those events a chance to be written without waiting for hundreds more items. Spring’s @Scheduled annotation can run a no-argument method on a fixed delay. For draining, fixed delay fits well because the next run is counted after the current run finishes. That prevents the timer path from stacking the same scheduled drain on top of itself when a write takes longer than usual:
package com.alex.demo.events;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Service
public class ProductEventDrainScheduler {
private final ProductEventBuffer productEventBuffer;
public ProductEventDrainScheduler(ProductEventBuffer productEventBuffer) {
this.productEventBuffer = productEventBuffer;
}
@Scheduled(fixedDelayString = "${product.events.flush-delay-ms:1000}")
public void drainByTimer() {
productEventBuffer.drain();
}
}The timer calls the same drain method as the threshold path. That keeps the write rules in one place. We do not want separate size-drain behavior and timer-drain behavior drifting apart, because both triggers are only different reasons to start the same drain process.
Configuration keeps the delay easy to see. Hardcoding the value inside the annotation makes timing changes depend on code edits. With a property, the service can keep drain timing near the rest of its application configuration:
product.events.flush-delay-ms=1000
spring.task.scheduling.pool.size=2The scheduled method also needs scheduling to be enabled. In a small Spring Boot example like this, that can go on the main application class:
package com.alex.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}With a delay of 1000 milliseconds, quiet items normally wait around one second or less before the timer tries to flush them. Raising that value reduces small writes during quiet periods but lets data wait longer. Lowering it writes partial batches sooner, but the database may receive more small batches.
The timer should not be treated as a fix for a slow database. If a flush takes a long time, the queue can still grow while the writer is busy. The timer protects low-volume data from waiting too long, while the queue limit, threshold, drain guard, and metrics control how much write pressure the service creates.
Database Batch Writes
Rows reach the database through the writer. For this flow, JdbcTemplate.batchUpdate gives us a direct way to send a list of parameterized inserts through JDBC batching. The SQL stays visible, the batch boundary is easy to read, and the list size tells the writer how many parameter sets it will send:
package com.alex.demo.events;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public class ProductEventWriter {
private final JdbcTemplate jdbcTemplate;
public ProductEventWriter(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void write(List<ProductEvent> events) {
jdbcTemplate.batchUpdate(
"""
INSERT INTO product_events (product_id, action, occurred_at)
VALUES (?, ?, ?)
""",
events,
events.size(),
(PreparedStatement statement, ProductEvent event) -> {
statement.setLong(1, event.productId());
statement.setString(2, event.action());
statement.setTimestamp(3, Timestamp.from(event.occurredAt()));
}
);
}
}Here, we place @Transactional on the writer so one batch call runs inside one transaction. If the batch commits, the rows are stored as a group. If the call fails and the transaction rolls back, the application does not treat that batch as stored.
The batch size affects database pressure. Groups of 50 create more write calls, but each transaction is smaller. Groups of 500 create fewer calls, but each transaction carries more row changes. Very large groups can reduce round trips, yet they can also hold locks longer, take more time to roll back after failure, and keep a connection busy longer than the rest of the service expects.
Indexes, constraints, generated values, triggers, and foreign keys all add cost to a row write. Batching reduces repeated call overhead, but the database still checks and stores every row. That is why we should not keep raising MAX_BATCH_SIZE only because the service can hold more items in memory.
Connection pool pressure belongs in the database write discussion too. If several service instances drain at the same time, they can all borrow connections and send batches. That can help when the database has capacity, but it can also make a slow database worse. The local drain guard keeps one buffer from writing through more than one local drain at the same time:
package com.alex.demo.events;
import java.util.concurrent.atomic.AtomicBoolean;
private final AtomicBoolean draining = new AtomicBoolean(false);
void drain() {
if (!draining.compareAndSet(false, true)) {
return;
}
try {
drainAvailableBatches();
} finally {
draining.set(false);
}
}The guard does not coordinate every service instance in a deployment. It only protects this buffer inside this running JVM. That still helps because it keeps the local service from creating overlapping writes from the threshold path and the timer path.
Failure Policy
Write failures need a defined response because items have already left the queue by the time the writer receives the batch. The right response depends on the value of the data. Some data can be dropped after counting the failure. Business data usually needs retry, durable fallback storage, or a stop point that keeps the service from accepting more items until the database recovers.
We can make the drain result visible by letting the writer throw an exception and catching it around the batch write. This version focuses on failure reporting without adding a full retry system:
package com.alex.demo.events;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log =
LoggerFactory.getLogger(ProductEventBuffer.class);
private void writeBatch(List<ProductEvent> batch) {
try {
writer.write(batch);
} catch (RuntimeException ex) {
log.warn("Failed to write product event batch with size {}", batch.size(), ex);
throw ex;
}
}The log includes the batch size so operators can tell how much data was involved. The exception is rethrown because swallowing the failure would make the service look healthier than it is. Production code could retry, move the batch into durable storage, or stop the drain loop after recording the failure.
Retries need duplicate protection. The application can lose the connection while the database is completing a transaction, or it can receive an error after the database has already accepted the call. When the service retries, it should be safe if some rows were already written. Also, a unique event id is a common way to protect inserts from duplicate attempts:
package com.alex.demo.events;
import java.time.Instant;
import java.util.UUID;
public record ProductEvent(
UUID eventId,
long productId,
String action,
Instant occurredAt
) {
}Adding eventId gives the database a stable value it can protect with a unique constraint. If the same batch is attempted again, the table can reject or ignore duplicates based on the SQL style chosen for that database. The main rule stays the same. Repeated delivery should not corrupt stored data.
Shutdown is part of failure policy too. During normal shutdown, the service should stop accepting new items and give the queue a chance to drain before the process exits. That drain should have a time limit because shutdown cannot wait forever. Data that must survive a restart should be placed in durable storage before the service treats it as accepted.
Metrics make the drain behavior visible while the service is running. Queue size, accepted item count, rejected item count, batch size, flush duration, successful flush count, failed flush count, and oldest queued item age all help explain what the batcher is doing. Without those numbers, the service can appear fine while the queue grows or the database falls behind.
Conclusion
Batching and queue draining come down to three mechanics. First we decide where items wait, what causes a flush, and how the writer handles each group. The buffer holds accepted items in memory for a short time, the size threshold starts a write during higher traffic, and the timer flush keeps quiet traffic from waiting too long. From there, batch size sets the tradeoff between throughput, latency, and database pressure. With a hard queue limit, a fixed batch cap, and a defined response for failures, the service can move items from request flow to storage without turning every incoming item into its own database call.


