java 97 lines · 4 tabs

Nightly Cleanup of Expired Refresh Tokens with Spring @Scheduled and a Bulk Delete Query

Shared by codesnips Jul 2026
4 tabs
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.Instant;
import java.util.UUID;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {

    @Modifying
    @Query("delete from RefreshToken t where t.expiresAt < :now")
    int deleteAllByExpiresAtBefore(@Param("now") Instant now);
}
4 files · java Explain with highlit

This snippet shows the common housekeeping problem of pruning stale rows on a schedule: refresh tokens that have passed their expiry date accumulate forever unless something removes them, bloating the table and slowing lookups. The pattern is a single background task that runs once a night and issues one bulk DELETE instead of loading and removing entities one by one.

In RefreshToken entity the row carries an expiresAt timestamp and an Instant-based isExpired() helper. The unique token column and index on expiresAt matter here — the cleanup query filters on expiresAt, so an index keeps the nightly scan cheap even as the table grows. Storing timestamps as Instant avoids time-zone ambiguity, which is important when the delete boundary is Instant.now().

RefreshTokenRepository declares deleteAllByExpiresAtBefore as a modifying bulk query. The @Modifying annotation tells Spring Data this is a write, not a SELECT, and @Query with delete from RefreshToken t where t.expiresAt < :now compiles to a single JPQL bulk delete. This bypasses the persistence context entirely — no entities are hydrated into memory — which is exactly what is wanted for a mass purge. The method returns the affected row count so the job can log how much it removed.

TokenCleanupJob wires it together. @Scheduled(cron = "0 0 3 * * *") fires at 03:00 every day; the zone attribute pins the schedule to UTC so it behaves identically across servers. The cleanupExpiredTokens method is annotated @Transactional because a bulk @Modifying query must run inside a transaction, and it passes Instant.now() as the cutoff. The trade-off of a bulk delete is that it does not fire JPA lifecycle callbacks or cascade through the persistence context, so it should only be used where those side effects are unnecessary — which is true for expired tokens.

A pitfall worth noting: @Scheduled runs on a small default thread pool, and a huge delete could block other scheduled tasks, so very large tables may warrant batching or a dedicated executor. Scheduling must also be enabled once via @EnableScheduling, shown in SchedulingConfig. This approach fits any periodic purge — sessions, one-time codes, audit rows — where a simple cron plus one indexed delete is far cheaper than a full ORM sweep.


Related snips

Share this code

Here's the card — post it anywhere.

Nightly Cleanup of Expired Refresh Tokens with Spring @Scheduled and a Bulk Delete Query — share card
Link copied