Monitor Swift Scheduled Tasks and Background Jobs with CronPeek

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

Swift on the server is no longer experimental. Vapor powers production APIs. Hummingbird runs lightweight microservices. Swift ArgumentParser builds CLI tools that teams schedule via crontab and launchd. But every one of these scheduled tasks shares the same blind spot: when they fail silently, nobody notices until the damage is done.

A Vapor queue worker that throws an unhandled error stops processing jobs. A launchd agent on macOS that crashes on launch never runs again until someone manually restarts it. A CLI tool scheduled via crontab that segfaults produces no output — cron swallows the error and moves on. The server is up. The health check passes. The work is not happening.

CronPeek solves this with dead man's switch monitoring. Your Swift task pings an endpoint after every successful run. If the ping stops arriving, you get an alert. This guide covers every major way Swift runs scheduled work — Vapor Queues, ArgumentParser CLI tools, DispatchSourceTimer, launchd agents, and Hummingbird — with production-ready code examples.

Why Swift Background Jobs Fail Silently

Swift's type system catches many bugs at compile time, but scheduled tasks fail for reasons the compiler cannot prevent:

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 is a mechanism that 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, deployment mistakes — because it monitors the absence of success rather than the presence of failure.

CronPeek Ping Wrapper in Swift

Here is a reusable CronPeek client using Swift's modern URLSession async/await API. This works on both macOS and Linux with Swift 5.9+:

import Foundation

/// Lightweight CronPeek client for heartbeat pings.
/// Uses URLSession async/await — no third-party dependencies.
struct CronPeekClient {
    let baseURL: String
    let session: URLSession
    let timeout: TimeInterval

    init(
        baseURL: String = "https://cronpeek.web.app/api/v1/ping",
        timeout: TimeInterval = 5.0
    ) {
        self.baseURL = baseURL
        self.timeout = timeout
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = timeout
        config.timeoutIntervalForResource = timeout
        self.session = URLSession(configuration: config)
    }

    /// Ping CronPeek after a successful job run.
    func ping(monitorID: String) async {
        await send(url: "\(baseURL)/\(monitorID)")
    }

    /// Report a failure to CronPeek for an immediate alert.
    func fail(monitorID: String, message: String? = nil) async {
        var url = "\(baseURL)/\(monitorID)/fail"
        if let message = message {
            let encoded = message.addingPercentEncoding(
                withAllowedCharacters: .urlQueryAllowed
            ) ?? message
            url += "?msg=\(encoded)"
        }
        await send(url: url)
    }

    private func send(url: String) async {
        guard let url = URL(string: url) else {
            print("[CronPeek] Invalid URL, skipping ping")
            return
        }
        do {
            let (_, response) = try await session.data(from: url)
            if let http = response as? HTTPURLResponse,
               http.statusCode != 200 {
                print("[CronPeek] Unexpected status: \(http.statusCode)")
            }
        } catch {
            // Never crash on monitoring failure.
            // CronPeek's grace period handles transient issues.
            print("[CronPeek] Ping failed: \(error.localizedDescription)")
        }
    }
}

Key design decisions: the 5-second timeout prevents a CronPeek outage from blocking your service. Errors are printed but never thrown — a monitoring failure must never cause a production outage. The fail method accepts an optional message that appears in your CronPeek dashboard for faster debugging.

Usage is straightforward:

let cronpeek = CronPeekClient()

// After a successful job
await cronpeek.ping(monitorID: "mon_billing_swift_001")

// After a caught error
await cronpeek.fail(
    monitorID: "mon_billing_swift_001",
    message: "Stripe API returned 503"
)

Integration with Vapor's Queues Framework

Vapor's Queues package provides background job processing with Redis or database-backed drivers. The framework supports lifecycle hooks through JobEventDelegate, which is the clean way to integrate CronPeek without modifying every job handler.

import Vapor
import Queues

/// Monitors all Vapor queue jobs via CronPeek heartbeats.
struct CronPeekJobDelegate: JobEventDelegate {
    let cronpeek: CronPeekClient
    let monitorPrefix: String

    init(monitorPrefix: String = "mon_vapor") {
        self.cronpeek = CronPeekClient()
        self.monitorPrefix = monitorPrefix
    }

    func dispatched(job: JobEventData, eventLoop: EventLoop) -> EventLoopFuture<Void> {
        // No ping on dispatch — only on completion
        eventLoop.makeSucceededVoidFuture()
    }

    func didDequeue(jobId: String, eventLoop: EventLoop) -> EventLoopFuture<Void> {
        eventLoop.makeSucceededVoidFuture()
    }

    func success(jobId: String, eventLoop: EventLoop) -> EventLoopFuture<Void> {
        let monitorID = "\(monitorPrefix)_\(jobId)"
        return eventLoop.performWithTask {
            await cronpeek.ping(monitorID: monitorID)
        }
    }

    func error(jobId: String, error: Error, eventLoop: EventLoop) -> EventLoopFuture<Void> {
        let monitorID = "\(monitorPrefix)_\(jobId)"
        return eventLoop.performWithTask {
            await cronpeek.fail(
                monitorID: monitorID,
                message: error.localizedDescription
            )
        }
    }
}

Register the delegate in your Vapor configuration:

// In configure.swift
func configure(_ app: Application) throws {
    // ... Redis, database, routes setup ...

    // Register CronPeek monitoring for all queue jobs
    app.queues.configuration.addJobEventDelegate(
        CronPeekJobDelegate(monitorPrefix: "mon_vapor")
    )

    // Register your jobs as usual
    app.queues.add(SendWeeklyReportJob())
    app.queues.add(CleanExpiredTokensJob())
    app.queues.add(SyncInventoryJob())

    try app.queues.startInProcessJobs()
}

Now every Vapor queue job automatically reports success or failure to CronPeek. If a job type-specific monitor makes more sense for your setup, override the monitor ID based on the job name instead of the job ID.

For recurring scheduled jobs in Vapor, add the ping directly:

import Vapor
import Queues

struct DailyReportJob: AsyncScheduledJob {
    let cronpeek = CronPeekClient()

    func run(context: QueueContext) async throws {
        let db = context.application.db

        // Generate the report
        let users = try await User.query(on: db)
            .filter(\.$isActive == true)
            .all()
        let revenue = try await Order.query(on: db)
            .filter(\.$createdAt >= Calendar.current.startOfDay(for: Date()))
            .sum(\.$total)

        try await EmailService.sendDailyReport(
            userCount: users.count,
            revenue: revenue ?? 0
        )

        // Ping CronPeek on success
        await cronpeek.ping(monitorID: "mon_daily_report_001")
    }
}

Swift ArgumentParser CLI Tools with CronPeek

Swift ArgumentParser builds excellent CLI tools. When you schedule them via crontab or launchd, CronPeek ensures they actually run. Here is a complete example of a data export tool with monitoring:

import ArgumentParser
import Foundation

@main
struct ExportAnalytics: AsyncParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "Export analytics data to S3, monitored by CronPeek"
    )

    @Option(help: "CronPeek monitor ID")
    var monitorID: String = "mon_export_analytics_001"

    @Option(help: "Number of days to export")
    var days: Int = 1

    @Option(help: "S3 bucket name")
    var bucket: String

    func run() async throws {
        let cronpeek = CronPeekClient()
        let startDate = Calendar.current.date(
            byAdding: .day,
            value: -days,
            to: Date()
        )!

        do {
            // Step 1: Query the database
            print("Querying analytics from \(startDate)...")
            let records = try await AnalyticsDB.shared.query(since: startDate)
            print("Found \(records.count) records")

            guard !records.isEmpty else {
                print("No records to export, pinging CronPeek anyway")
                await cronpeek.ping(monitorID: monitorID)
                return
            }

            // Step 2: Serialize to Parquet
            let parquetData = try ParquetEncoder.encode(records)
            let filename = "analytics_\(ISO8601DateFormatter().string(from: startDate)).parquet"

            // Step 3: Upload to S3
            print("Uploading \(filename) to s3://\(bucket)/...")
            try await S3Client.shared.upload(
                data: parquetData,
                bucket: bucket,
                key: "exports/\(filename)"
            )

            print("Export complete: \(records.count) records uploaded")
            await cronpeek.ping(monitorID: monitorID)

        } catch {
            print("Export failed: \(error)")
            await cronpeek.fail(
                monitorID: monitorID,
                message: "\(error)"
            )
            // Re-throw so the process exits with non-zero code
            throw error
        }
    }
}

Schedule it via crontab on Linux:

# Run analytics export every 6 hours
0 */6 * * * /usr/local/bin/export-analytics --bucket my-analytics-bucket 2>&1 | logger -t export-analytics

Or via launchd on macOS, create ~/Library/LaunchAgents/com.mycompany.export-analytics.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.mycompany.export-analytics</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/export-analytics</string>
    <string>--bucket</string>
    <string>my-analytics-bucket</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>6</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/tmp/export-analytics.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/export-analytics.err</string>
</dict>
</plist>

If launchd fails to start the process (code signing issue, binary not found, permission denied), CronPeek detects the missing ping and alerts you. This is something launchctl list alone cannot tell you — a loaded agent is not the same as a running agent.

Using DispatchSourceTimer with CronPeek Heartbeats

DispatchSourceTimer is the low-level GCD primitive for recurring work. It runs on a dispatch queue and fires a closure at a set interval. The danger: if the timer's reference is released, or the queue is blocked, the timer stops silently. CronPeek catches this.

import Foundation

class HeartbeatScheduler {
    private var timer: DispatchSourceTimer?
    private let queue = DispatchQueue(
        label: "com.myapp.scheduler",
        qos: .utility
    )
    private let cronpeek = CronPeekClient()
    private let monitorID: String

    init(monitorID: String) {
        self.monitorID = monitorID
    }

    /// Start a recurring task with CronPeek heartbeat monitoring.
    func start(interval: TimeInterval, task: @escaping () async throws -> Void) {
        let timer = DispatchSource.makeTimerSource(queue: queue)
        timer.schedule(
            deadline: .now(),
            repeating: interval,
            leeway: .seconds(1)
        )

        timer.setEventHandler { [weak self] in
            guard let self = self else { return }
            Task {
                do {
                    try await task()
                    await self.cronpeek.ping(monitorID: self.monitorID)
                } catch {
                    await self.cronpeek.fail(
                        monitorID: self.monitorID,
                        message: "\(error)"
                    )
                }
            }
        }

        timer.setCancelHandler {
            print("[Scheduler] Timer cancelled for \(self.monitorID)")
        }

        timer.resume()
        self.timer = timer
    }

    func stop() {
        timer?.cancel()
        timer = nil
    }

    deinit {
        stop()
    }
}

// Usage
let scheduler = HeartbeatScheduler(monitorID: "mon_cache_warm_001")
scheduler.start(interval: 300) { // Every 5 minutes
    try await CacheManager.shared.warmProductCache()
}

// Keep the process alive
RunLoop.current.run()

The [weak self] capture is critical. If the scheduler is deallocated, the timer cancels cleanly and CronPeek detects the missing heartbeat. Without this pattern, a strong reference cycle keeps the timer alive but orphaned — it fires into a deallocated context and crashes.

Server-Side Swift on Linux: Vapor and Hummingbird Scheduled Tasks

Server-side Swift typically runs on Linux in Docker containers. Both Vapor and Hummingbird support background tasks, and both need monitoring.

Hummingbird Scheduled Tasks

Hummingbird 2 uses Swift's structured concurrency for background work. Here is how to add CronPeek monitoring to a Hummingbird service with periodic tasks:

import Hummingbird
import Foundation

struct PeriodicTaskRunner {
    let cronpeek = CronPeekClient()

    func runMetricsAggregation(app: HBApplication) {
        Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(600)) // 10 minutes

                do {
                    let metrics = try await MetricsService.aggregate(
                        since: Date().addingTimeInterval(-600)
                    )
                    try await MetricsService.store(metrics)
                    print("Aggregated \(metrics.count) metric points")

                    await cronpeek.ping(monitorID: "mon_hb_metrics_001")
                } catch {
                    print("Metrics aggregation failed: \(error)")
                    await cronpeek.fail(
                        monitorID: "mon_hb_metrics_001",
                        message: "\(error)"
                    )
                }
            }
        }
    }

    func runStaleSessionCleanup(app: HBApplication) {
        Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(3600)) // 1 hour

                do {
                    let deleted = try await SessionStore.shared.deleteExpired()
                    print("Cleaned \(deleted) expired sessions")

                    await cronpeek.ping(monitorID: "mon_hb_sessions_001")
                } catch {
                    print("Session cleanup failed: \(error)")
                    await cronpeek.fail(
                        monitorID: "mon_hb_sessions_001",
                        message: "\(error)"
                    )
                }
            }
        }
    }
}

Vapor Lifecycle Handler

For Vapor, you can wrap periodic tasks in a LifecycleHandler that starts and stops cleanly with the application:

import Vapor

struct CronPeekLifecycleHandler: LifecycleHandler {
    let cronpeek = CronPeekClient()
    let tasks: [(monitorID: String, interval: TimeInterval, work: @Sendable () async throws -> Void)]

    func didBoot(_ app: Application) throws {
        for task in tasks {
            let cronpeek = self.cronpeek
            let monitorID = task.monitorID
            let interval = task.interval
            let work = task.work

            app.eventLoopGroup.next().scheduleRepeatedTask(
                initialDelay: .seconds(0),
                delay: .seconds(Int64(interval))
            ) { repeatedTask in
                Task {
                    do {
                        try await work()
                        await cronpeek.ping(monitorID: monitorID)
                    } catch {
                        await cronpeek.fail(
                            monitorID: monitorID,
                            message: "\(error)"
                        )
                    }
                }
            }
        }
    }
}

// In configure.swift
app.lifecycle.use(CronPeekLifecycleHandler(tasks: [
    (
        monitorID: "mon_vapor_cleanup_001",
        interval: 600,
        work: { try await TokenService.cleanExpired() }
    ),
    (
        monitorID: "mon_vapor_digest_001",
        interval: 86400,
        work: { try await EmailService.sendDailyDigest() }
    ),
]))

Error Reporting with the /fail Endpoint

The difference between a missing ping and an explicit failure report matters for incident response. A missing ping means something stopped — could be a crash, a hang, a deployment issue. An explicit /fail call means the code ran, hit an error, and reported it. CronPeek treats these differently:

Best practice: always call /fail in your catch block, then re-throw or exit with a non-zero code. This gives you both the immediate CronPeek alert and the standard error handling:

func runDatabaseMigration() async throws {
    let cronpeek = CronPeekClient()
    let monitorID = "mon_migration_001"

    do {
        try await MigrationRunner.run(migrations: [
            AddUserPreferencesTable.self,
            BackfillDefaultPreferences.self,
        ])
        await cronpeek.ping(monitorID: monitorID)
    } catch let error as DatabaseError where error.isConstraintViolation {
        // Known error type: report and exit gracefully
        await cronpeek.fail(
            monitorID: monitorID,
            message: "Constraint violation: \(error.column ?? "unknown")"
        )
        throw error
    } catch {
        // Unknown error: report everything
        await cronpeek.fail(
            monitorID: monitorID,
            message: String(describing: error).prefix(200).description
        )
        throw error
    }
}

Truncating the error message to 200 characters prevents excessively long stack traces from bloating the ping URL. CronPeek accepts messages up to 1,000 characters.

Kubernetes Deployment with Swift Docker Images

Server-side Swift runs in Docker containers using Apple's official Swift images. Here is a multi-stage Dockerfile for a Swift CLI tool with CronPeek monitoring baked in:

# Build stage
FROM swift:5.10-jammy AS builder
WORKDIR /app
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources/ Sources/
RUN swift build -c release --static-swift-stdlib

# Runtime stage
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.build/release/export-analytics /usr/local/bin/
ENTRYPOINT ["export-analytics"]

Deploy it as a Kubernetes CronJob:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: swift-analytics-export
  labels:
    app: analytics-export
    runtime: swift
spec:
  schedule: "0 */6 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 5
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: export
            image: your-registry/swift-analytics-export:latest
            args:
            - "--bucket"
            - "my-analytics-bucket"
            - "--monitor-id"
            - "mon_k8s_export_001"
            resources:
              requests:
                memory: "256Mi"
                cpu: "250m"
              limits:
                memory: "512Mi"
                cpu: "500m"
            env:
            - name: AWS_REGION
              value: "us-east-1"
            - name: AWS_ACCESS_KEY_ID
              valueFrom:
                secretKeyRef:
                  name: aws-credentials
                  key: access-key-id
            - name: AWS_SECRET_ACCESS_KEY
              valueFrom:
                secretKeyRef:
                  name: aws-credentials
                  key: secret-access-key

The CronPeek ping is built into the Swift binary itself (via the ArgumentParser run() method shown earlier), so no sidecar container is needed. If you cannot modify the binary, use the curl-based sidecar approach:

          containers:
          - name: export
            image: your-registry/swift-export:latest
            command:
            - /bin/sh
            - -c
            - |
              /usr/local/bin/export-analytics --bucket my-bucket && \
              curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_export_001 || \
              curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_export_001/fail

The shell && ensures the ping only fires after a successful exit. The || branch hits /fail if the main command returns a non-zero exit code.

Pricing: CronPeek vs Cronitor for Swift Teams

Swift teams running server-side applications typically have 10–50 scheduled tasks across Vapor workers, CLI tools, and background jobs. Here is how the costs compare:

For a typical server-side Swift deployment with 30 scheduled tasks, CronPeek saves over $70/month compared to Cronitor. That is $840/year redirected to actual infrastructure.

Start monitoring your Swift jobs

Free for up to 5 monitors. No credit card required. Set up a dead man's switch for your Vapor queue workers, ArgumentParser CLI tools, and Hummingbird background tasks in under 60 seconds.

Start Free Monitoring

Best Practices for Swift Cron Monitoring

FAQ

How do I monitor a Swift cron job or scheduled task for silent failures?

After your Swift scheduled task completes, send an HTTP GET request to your CronPeek ping URL using URLSession and async/await. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. Use a fire-and-forget pattern by wrapping errors in a do/catch so the ping never crashes your service.

Does CronPeek work with Vapor's Queues framework?

Yes. Vapor Queues supports lifecycle hooks through the JobEventDelegate protocol. Implement success and error methods to ping CronPeek after each job finishes. The ping is a single HTTP GET using URLSession that takes milliseconds. You can also add pings directly inside AsyncScheduledJob implementations for recurring scheduled tasks.

Can I use CronPeek with Swift ArgumentParser CLI tools scheduled via crontab?

Yes. Swift ArgumentParser supports async run() methods. After your CLI tool completes its work, call the CronPeekClient.ping() method before exiting. If the process crashes, hangs, or is never launched by cron, CronPeek detects the missing ping and sends an alert. This works with both Linux crontab and macOS launchd scheduling.

How do I monitor launchd agents written in Swift on macOS?

Add a CronPeek ping at the end of your Swift tool's execution. If launchd fails to start the agent due to code signing issues, missing binaries, or permission errors, CronPeek detects the missing heartbeat and alerts you. This catches problems that launchctl list cannot — a loaded agent is not the same as a running agent.

How much does Swift 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 teams running multiple Swift scheduled tasks across Vapor, Hummingbird, and CLI tools.

The Peek Suite

CronPeek is part of a family of developer tools: