Monitor Kotlin Scheduled Tasks and Coroutine Jobs with CronPeek

Published March 29, 2026 · 16 min read · By CronPeek

Kotlin runs scheduled work everywhere. Spring Boot @Scheduled methods process nightly reports. Ktor services launch coroutines that poll queues every thirty seconds. Quartz Scheduler fires complex job chains across microservices. kotlinx.coroutines powers delay-based loops in standalone daemons. But all of these scheduling patterns share a critical weakness: when they fail, they fail silently.

A Spring Boot @Scheduled method that throws an uncaught exception stops being invoked — the thread pool marks the task as failed and never reschedules it. A coroutine launched with launch that throws an unhandled exception cancels its parent scope, potentially killing every other coroutine in the application. A Quartz job that runs for longer than its trigger interval causes overlapping executions that corrupt shared state. The JVM stays up. The health check returns 200. The work is not happening.

CronPeek solves this with dead man's switch monitoring. Your Kotlin task pings an endpoint after every successful run. If the ping stops arriving, you get an alert. This guide covers every major way Kotlin runs scheduled work — Spring Boot, Ktor, Quartz, raw coroutines, crontab scripts, and Kubernetes CronJobs — with production-ready code examples.

Why Kotlin Scheduled Tasks Fail Silently

Kotlin's null safety and type system prevent many bugs at compile time, but scheduled tasks fail for reasons no type checker can catch:

None of these trigger traditional uptime monitors or HTTP health checks. You need a system that detects when something stops happening.

The Dead Man's Switch Pattern

A dead man's switch triggers when it stops receiving a signal. In physical systems, a train operator holds a lever; if they let go, the brakes engage. In software, your scheduled task sends an HTTP ping after every successful run. If the ping stops arriving within an expected window, CronPeek triggers an alert.

The pattern works in three steps:

  1. Create a monitor on CronPeek with the expected interval (e.g., every 10 minutes). Set a grace period slightly longer than the interval to absorb jitter.
  2. Ping the monitor at the end of each successful run by sending an HTTP GET to https://cronpeek.web.app/api/v1/ping/YOUR_MONITOR_ID.
  3. Get alerted via email, Slack, or webhook when the ping stops arriving. Optionally, hit /fail for immediate alerts on known errors.

This catches every failure mode — crashes, hangs, missed schedules, OOM kills, coroutine cancellation, deployment mistakes — because it monitors the absence of success rather than the presence of failure.

Reusable CronPeekClient in Kotlin

Here is a reusable CronPeek client using OkHttp. It works in any Kotlin project — Spring Boot, Ktor, standalone scripts, or Android backends. Add com.squareup.okhttp3:okhttp:4.12.0 to your dependencies:

import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit

/**
 * Lightweight CronPeek client for heartbeat pings.
 * Uses OkHttp with a 5-second timeout. Never throws on failure.
 */
class CronPeekClient(
    private val baseUrl: String = "https://cronpeek.web.app/api/v1/ping",
    timeoutSeconds: Long = 5L
) {
    private val client = OkHttpClient.Builder()
        .connectTimeout(timeoutSeconds, TimeUnit.SECONDS)
        .readTimeout(timeoutSeconds, TimeUnit.SECONDS)
        .writeTimeout(timeoutSeconds, TimeUnit.SECONDS)
        .build()

    /** Ping CronPeek after a successful job run. */
    fun ping(monitorId: String) {
        send("$baseUrl/$monitorId")
    }

    /** Report a failure to CronPeek for an immediate alert. */
    fun fail(monitorId: String, message: String? = null) {
        val url = buildString {
            append("$baseUrl/$monitorId/fail")
            if (!message.isNullOrBlank()) {
                val encoded = URLEncoder.encode(
                    message.take(500), "UTF-8"
                )
                append("?msg=$encoded")
            }
        }
        send(url)
    }

    private fun send(url: String) {
        try {
            val request = Request.Builder().url(url).get().build()
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful) {
                    System.err.println(
                        "[CronPeek] Unexpected status: ${response.code}"
                    )
                }
            }
        } catch (e: Exception) {
            // Never crash on monitoring failure.
            // CronPeek's grace period handles transient issues.
            System.err.println("[CronPeek] Ping failed: ${e.message}")
        }
    }
}

Key design decisions: the 5-second timeout prevents a CronPeek outage from blocking your service. Exceptions are caught and logged but never re-thrown — a monitoring failure must never cause a production outage. The fail method truncates messages to 500 characters to prevent bloated URLs.

If you prefer ktor-client (useful in Ktor projects to avoid adding OkHttp), here is the equivalent using coroutines:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import java.net.URLEncoder

class CronPeekClientKtor(
    private val baseUrl: String = "https://cronpeek.web.app/api/v1/ping"
) {
    private val client = HttpClient(CIO) {
        engine {
            requestTimeout = 5_000
            endpoint { connectTimeout = 5_000 }
        }
    }

    suspend fun ping(monitorId: String) {
        send("$baseUrl/$monitorId")
    }

    suspend fun fail(monitorId: String, message: String? = null) {
        val url = buildString {
            append("$baseUrl/$monitorId/fail")
            if (!message.isNullOrBlank()) {
                append("?msg=${URLEncoder.encode(message.take(500), "UTF-8")}")
            }
        }
        send(url)
    }

    private suspend fun send(url: String) {
        try {
            client.get(url)
        } catch (e: Exception) {
            System.err.println("[CronPeek] Ping failed: ${e.message}")
        }
    }
}

Usage is straightforward with either client:

val cronpeek = CronPeekClient()

// After a successful job
cronpeek.ping("mon_billing_kotlin_001")

// After a caught error
cronpeek.fail(
    "mon_billing_kotlin_001",
    "Stripe API returned 503"
)

Spring Boot @Scheduled with AOP Monitoring

Spring Boot's @Scheduled annotation is the most common way to run periodic tasks in Kotlin. The problem: if a scheduled method throws an exception, Spring logs it and never invokes the method again for fixed-rate tasks. The application stays up, the health check passes, the work stops.

Instead of adding CronPeek calls to every scheduled method, use an AOP aspect that wraps all @Scheduled methods automatically:

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component

@Aspect
@Component
class CronPeekScheduledAspect(
    private val cronpeek: CronPeekClient = CronPeekClient()
) {
    /**
     * Wraps every @Scheduled method with CronPeek monitoring.
     * Monitor ID is derived from the class and method name.
     */
    @Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
    fun monitorScheduledTask(joinPoint: ProceedingJoinPoint): Any? {
        val className = joinPoint.target.javaClass.simpleName
        val methodName = joinPoint.signature.name
        val monitorId = "mon_spring_${className}_${methodName}"
            .lowercase()
            .replace(Regex("[^a-z0-9_]"), "_")

        return try {
            val result = joinPoint.proceed()
            cronpeek.ping(monitorId)
            result
        } catch (e: Exception) {
            cronpeek.fail(monitorId, "${e.javaClass.simpleName}: ${e.message}")
            throw e // Re-throw so Spring's error handling still applies
        }
    }
}

Now every @Scheduled method in your application is automatically monitored. The monitor ID is derived from the class and method name, so BillingService.processOverdueInvoices() becomes mon_spring_billingservice_processoverdueinvoices. No changes to existing scheduled methods are needed.

Here is a typical Spring Boot scheduled service that the aspect monitors transparently:

import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service

@Service
class ReportService(
    private val userRepository: UserRepository,
    private val orderRepository: OrderRepository,
    private val emailService: EmailService
) {
    @Scheduled(cron = "0 0 6 * * *") // Daily at 6 AM
    fun generateDailyReport() {
        val activeUsers = userRepository.countByActiveTrue()
        val todayRevenue = orderRepository.sumRevenueForToday()
        emailService.sendDailyReport(activeUsers, todayRevenue)
    }

    @Scheduled(fixedRate = 600_000) // Every 10 minutes
    fun cleanExpiredSessions() {
        val deleted = sessionRepository.deleteExpiredBefore(
            Instant.now().minus(Duration.ofHours(24))
        )
        logger.info("Cleaned $deleted expired sessions")
    }

    @Scheduled(fixedDelay = 30_000) // 30 seconds after last completion
    fun processWebhookQueue() {
        val pending = webhookQueueRepository.findPending(limit = 100)
        pending.forEach { webhook ->
            webhookProcessor.process(webhook)
        }
    }
}

Make sure to configure Spring's task scheduler with enough threads. The default single-threaded pool is a common source of silent failures:

import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.SchedulingConfigurer
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.scheduling.config.ScheduledTaskRegistrar

@Configuration
class SchedulerConfig : SchedulingConfigurer {
    override fun configureTasks(taskRegistrar: ScheduledTaskRegistrar) {
        val scheduler = ThreadPoolTaskScheduler()
        scheduler.poolSize = 5 // One thread per scheduled task
        scheduler.setThreadNamePrefix("scheduled-")
        scheduler.setErrorHandler { e ->
            // Log but don't swallow — the AOP aspect handles CronPeek
            System.err.println("Scheduled task error: ${e.message}")
        }
        scheduler.initialize()
        taskRegistrar.setTaskScheduler(scheduler)
    }
}

Ktor Coroutine-Based Scheduled Tasks

Ktor applications use structured concurrency for background work. Scheduled tasks are coroutines launched in the application's scope that loop with delay(). When the application shuts down, the scope is cancelled and coroutines are cleaned up — but unhandled exceptions in a coroutine cancel the entire scope silently.

import io.ktor.server.application.*
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

fun Application.configureScheduledTasks() {
    val cronpeek = CronPeekClientKtor()

    // Use SupervisorJob so one failing task doesn't kill the others
    val taskScope = CoroutineScope(
        SupervisorJob() + Dispatchers.Default +
        CoroutineName("scheduled-tasks")
    )

    // Metrics aggregation — every 10 minutes
    taskScope.launch {
        while (isActive) {
            delay(10.minutes)
            try {
                val count = MetricsService.aggregateLast10Minutes()
                println("Aggregated $count metric points")
                cronpeek.ping("mon_ktor_metrics_001")
            } catch (e: CancellationException) {
                throw e // Never catch CancellationException
            } catch (e: Exception) {
                println("Metrics aggregation failed: ${e.message}")
                cronpeek.fail("mon_ktor_metrics_001", e.message)
            }
        }
    }

    // Stale session cleanup — every hour
    taskScope.launch {
        while (isActive) {
            delay(60.minutes)
            try {
                val deleted = SessionStore.deleteExpired()
                println("Cleaned $deleted expired sessions")
                cronpeek.ping("mon_ktor_sessions_001")
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                println("Session cleanup failed: ${e.message}")
                cronpeek.fail("mon_ktor_sessions_001", e.message)
            }
        }
    }

    // Cache warming — every 30 seconds
    taskScope.launch {
        while (isActive) {
            delay(30.seconds)
            try {
                CacheWarmer.warmProductCache()
                cronpeek.ping("mon_ktor_cache_001")
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                cronpeek.fail("mon_ktor_cache_001", e.message)
            }
        }
    }

    // Cancel all tasks on application shutdown
    environment.monitor.subscribe(ApplicationStopped) {
        taskScope.cancel()
    }
}

Critical detail: always re-throw CancellationException. Catching it breaks structured concurrency — the coroutine becomes un-cancellable, and your application cannot shut down cleanly. CronPeek detects the missing heartbeat after shutdown, which is expected behavior, not a false alert (set your grace period accordingly during deployments).

The SupervisorJob is essential. Without it, a single failing coroutine cancels the entire scope, killing all other scheduled tasks. With SupervisorJob, each child coroutine fails independently.

Quartz Scheduler JobListener Integration

Quartz Scheduler is the heavyweight option for Kotlin/JVM scheduled tasks. It supports persistent job stores, clustering, misfire recovery, and complex trigger schedules. The JobListener interface lets you hook into every job execution without modifying individual Job classes:

import org.quartz.*

class CronPeekJobListener(
    private val cronpeek: CronPeekClient = CronPeekClient(),
    private val monitorPrefix: String = "mon_quartz"
) : JobListener {

    override fun getName(): String = "CronPeekJobListener"

    override fun jobToBeExecuted(context: JobExecutionContext) {
        // No ping before execution — only after
    }

    override fun jobExecutionVetoed(context: JobExecutionContext) {
        // Job was vetoed by a TriggerListener; report as failure
        val monitorId = buildMonitorId(context)
        cronpeek.fail(monitorId, "Job vetoed by trigger listener")
    }

    override fun jobWasExecuted(
        context: JobExecutionContext,
        jobException: JobExecutionException?
    ) {
        val monitorId = buildMonitorId(context)

        if (jobException != null) {
            cronpeek.fail(
                monitorId,
                "${jobException.javaClass.simpleName}: ${jobException.message}"
            )
        } else {
            cronpeek.ping(monitorId)
        }
    }

    private fun buildMonitorId(context: JobExecutionContext): String {
        val jobKey = context.jobDetail.key
        return "${monitorPrefix}_${jobKey.group}_${jobKey.name}"
            .lowercase()
            .replace(Regex("[^a-z0-9_]"), "_")
    }
}

Register the listener on your Quartz Scheduler instance:

import org.quartz.impl.StdSchedulerFactory

fun configureQuartz() {
    val scheduler = StdSchedulerFactory.getDefaultScheduler()

    // Register CronPeek listener for ALL jobs
    scheduler.listenerManager.addJobListener(
        CronPeekJobListener(monitorPrefix = "mon_quartz"),
        EverythingMatcher.allJobs()
    )

    // Define your jobs as usual
    val job = JobBuilder.newJob(InvoiceProcessingJob::class.java)
        .withIdentity("process-invoices", "billing")
        .build()

    val trigger = TriggerBuilder.newTrigger()
        .withIdentity("invoice-trigger", "billing")
        .withSchedule(
            CronScheduleBuilder.cronSchedule("0 0 */2 * * ?") // Every 2 hours
        )
        .build()

    scheduler.scheduleJob(job, trigger)
    scheduler.start()
}

Every Quartz job now reports its outcome to CronPeek automatically. The monitor ID includes the job group and name, so billing.process-invoices becomes mon_quartz_billing_process_invoices. If Quartz's misfire policy silently drops executions, CronPeek detects the missing pings and alerts you.

kotlinx.coroutines Delay-Based Periodic Tasks

For standalone Kotlin daemons or microservices that do not use Spring or Ktor, kotlinx.coroutines with delay() is the simplest scheduling mechanism. The pattern is a while(isActive) loop inside a coroutine:

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.minutes

suspend fun main() = coroutineScope {
    val cronpeek = CronPeekClient()

    // Data sync task — every 5 minutes
    launch {
        while (isActive) {
            try {
                val synced = DataSyncService.syncFromUpstream()
                println("Synced $synced records from upstream")
                cronpeek.ping("mon_datasync_001")
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                System.err.println("Data sync failed: ${e.message}")
                cronpeek.fail("mon_datasync_001", e.message)
            }
            delay(5.minutes)
        }
    }

    // Cleanup task — every 30 minutes
    launch {
        while (isActive) {
            try {
                TempFileCleanup.run()
                cronpeek.ping("mon_cleanup_001")
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                cronpeek.fail("mon_cleanup_001", e.message)
            }
            delay(30.minutes)
        }
    }

    println("Daemon started. Press Ctrl+C to stop.")
}

Be aware of a subtle bug: if delay() is placed before the work (as in some examples above for Ktor), the first execution happens after the delay. If placed after the work (as shown here), the first execution happens immediately. Choose based on your requirements. CronPeek does not care about the order — it only cares that pings arrive within the expected interval.

Another common pitfall: using GlobalScope.launch instead of structured concurrency. GlobalScope creates daemon coroutines that can be garbage-collected or cancelled unpredictably. Always use a CoroutineScope tied to the application lifecycle.

Kotlin Script Scheduled via Crontab

Kotlin scripts (.kts files) or compiled Kotlin CLI tools can be scheduled via crontab on Linux or macOS. This is common for data processing, report generation, and database maintenance. The CronPeek integration is straightforward:

#!/usr/bin/env kotlin

// file: export-data.main.kts
// Dependencies: @file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")

@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")

import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit

val MONITOR_ID = "mon_export_data_001"
val CRONPEEK_BASE = "https://cronpeek.web.app/api/v1/ping"

val http = OkHttpClient.Builder()
    .connectTimeout(5, TimeUnit.SECONDS)
    .readTimeout(5, TimeUnit.SECONDS)
    .build()

fun pingCronPeek(path: String) {
    try {
        val request = Request.Builder().url("$CRONPEEK_BASE/$path").build()
        http.newCall(request).execute().close()
    } catch (e: Exception) {
        System.err.println("[CronPeek] $path failed: ${e.message}")
    }
}

try {
    val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE)
    val outputFile = File("/data/exports/report-$today.csv")

    // Simulate data export work
    println("Exporting data for $today...")
    val records = DatabaseClient.queryRecordsSince(today)
    outputFile.writeText(records.toCsv())
    println("Exported ${records.size} records to ${outputFile.absolutePath}")

    // Report success
    pingCronPeek(MONITOR_ID)

} catch (e: Exception) {
    System.err.println("Export failed: ${e.message}")
    pingCronPeek("$MONITOR_ID/fail?msg=${e.message?.take(200)}")
    kotlin.system.exitProcess(1)
}

Schedule it with crontab:

# Run data export every day at 2 AM
0 2 * * * /usr/local/bin/kotlin /opt/scripts/export-data.main.kts 2>&1 | logger -t export-data

# Run inventory sync every 6 hours
0 */6 * * * /usr/local/bin/kotlin /opt/scripts/inventory-sync.main.kts 2>&1 | logger -t inventory-sync

If you compile to a JAR instead, replace the Kotlin script invocation with java -jar:

# Compiled Kotlin application
0 2 * * * java -jar /opt/apps/export-data.jar 2>&1 | logger -t export-data

If the crontab entry is removed, the system is rebooted, or the Kotlin runtime is not installed, CronPeek detects the missing ping and alerts you. Crontab itself has no built-in mechanism to tell you a job was never scheduled.

Kubernetes CronJob with Kotlin

Kotlin applications typically run on the JVM in Docker containers. Here is a multi-stage Dockerfile for a Kotlin CLI tool with CronPeek monitoring:

# Build stage
FROM gradle:8.5-jdk21 AS builder
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY src/ src/
RUN gradle shadowJar --no-daemon

# Runtime stage
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/build/libs/*-all.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Deploy it as a Kubernetes CronJob:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: kotlin-invoice-processor
  labels:
    app: invoice-processor
    runtime: kotlin
spec:
  schedule: "0 */2 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 5
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: processor
            image: your-registry/kotlin-invoice-processor:latest
            env:
            - name: CRONPEEK_MONITOR_ID
              value: "mon_k8s_invoices_001"
            - name: DB_URL
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: url
            resources:
              requests:
                memory: "256Mi"
                cpu: "250m"
              limits:
                memory: "512Mi"
                cpu: "500m"

The CronPeek ping is built into the Kotlin application itself (using the CronPeekClient class shown earlier). The monitor ID is passed as an environment variable so the same image can be deployed with different monitors across environments. If you cannot modify the application, use the curl sidecar pattern:

          containers:
          - name: processor
            image: your-registry/kotlin-processor:latest
            command:
            - /bin/sh
            - -c
            - |
              java -jar /app/app.jar && \
              curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_invoices_001 || \
              curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_invoices_001/fail

Pricing: CronPeek vs Cronitor for Kotlin Teams

Kotlin teams running JVM microservices typically have 20–100 scheduled tasks across Spring Boot services, Ktor apps, and Quartz jobs. Here is how the costs compare:

For a typical Kotlin microservices deployment with 40 scheduled tasks, CronPeek saves over $70/month compared to Cronitor. That is $840/year redirected to actual infrastructure.

Start monitoring your Kotlin jobs

Free for up to 5 monitors. No credit card required. Set up a dead man's switch for your Spring Boot @Scheduled tasks, Ktor coroutine jobs, and Quartz triggers in under 60 seconds.

Start monitoring for free

Best Practices for Kotlin Cron Monitoring

FAQ

How do I monitor a Kotlin scheduled task for silent failures?

After your Kotlin scheduled task completes, send an HTTP GET request to your CronPeek ping URL using OkHttp or ktor-client. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. This catches silent failures like coroutine cancellation, OOM kills, daemon thread shutdown, and Spring context destruction that silently stops @Scheduled methods.

Does CronPeek work with Spring Boot @Scheduled tasks?

Yes. You can integrate CronPeek with Spring Boot @Scheduled tasks using an AOP aspect that wraps every scheduled method automatically. The aspect pings CronPeek after successful execution and reports failures immediately via the /fail endpoint. This requires zero changes to your existing @Scheduled methods and works across your entire application.

Can I monitor Ktor coroutine-based scheduled tasks with CronPeek?

Yes. Ktor applications use kotlinx.coroutines for background work. Launch a coroutine in your Application module that loops with delay(), performs work, then pings CronPeek using the ktor-client-based CronPeekClientKtor. If the coroutine is cancelled, the server crashes, or the JVM is OOM-killed, CronPeek detects the missing heartbeat and alerts you.

How do I integrate CronPeek with Quartz Scheduler in Kotlin?

Quartz Scheduler supports JobListener interfaces that fire after every job execution. Implement a CronPeekJobListener that calls the ping endpoint in jobWasExecuted(). If the JobExecutionException is non-null, call the /fail endpoint instead. Register the listener globally with EverythingMatcher.allJobs() to monitor all jobs without modifying individual Job classes.

How much does Kotlin cron job monitoring cost with CronPeek?

CronPeek's free tier includes 5 monitors with no credit card required. The Starter plan at $9/month covers 50 monitors, and Pro at $29/month gives unlimited monitors. Compared to Cronitor at roughly $2 per monitor per month ($100/month for 50 monitors), CronPeek is over 10x cheaper for Kotlin teams running multiple Spring Boot services, Ktor apps, and Quartz Scheduler jobs.

The Peek Suite

CronPeek is part of a family of developer tools: