A persistent, zero-infrastructure task queue for Node.js — powered by SQLite.
Delayed scheduling · Cron jobs · Prometheus metrics · Atomic job locking · Exponential backoff · CPU thread isolation
No Redis. No Docker. No infrastructure.
Most apps don't need Redis. They need a reliable way to run background jobs without spinning up external services, managing connections, or paying for more infrastructure.
| Feature | External Redis/Infra | lite-q (SQLite) |
|---|---|---|
| Visibility | No visibility into what's running. | Inspect jobs, cron history, and Prometheus metrics — see failures, backlog, and latency. |
| Control | Wait for the next poll cycle. | Trigger what is available to run right now. |
| Performance | No insight into execution time. | See exactly how much time each job takes. |
| History | Jobs are gone once processed. | Full history of completed and failed jobs. |
Your data stays local. Because lite-q uses SQLite, all job data is stored on your local disk rather than being sent over a network. This eliminates:
- Network Latency: No round-trips to an external database.
- TLS Overhead: No encryption/decryption cycles for every job enqueue.
- Connection Complexity: No connection pooling or TCP handshake failures.
Every external dependency adds a new failure domain. lite-q eliminates all of it — your queue runs in-process. No TCP connections, no serialization hops, no dropped connections to retry.
lite-q uses SQLite as a persistent state machine. Jobs survive crashes, restarts, and deploys. Workers are isolated. Retries are automatic. And the entire thing is a single npm install.
Your App
│
├── queue.register('send-email', handler) ← runs once at boot
│
└── sendEmail({ to: 'user@example.com' }) ← runs on demand, anywhere
│
▼
SQLite (WAL mode)
│
┌─────┴─────────┐
│ │
pending → processing → completed
└→ failed (after retries exhausted)
npm install @km-dev/lite-qRequirements: Node.js ≥ 18.0.0
import { LiteQ } from '@km-dev/lite-q';
const queue = new LiteQ({ storagePath: './jobs.db' });
// Register an I/O handler — returns a typed enqueuer function
const sendEmail = queue.register<{ to: string; subject: string }>(
'send-email',
async (job) => {
await mailer.send(job.data.to, job.data.subject);
return { sent: true };
}
);
// Register a CPU handler — runs in a dedicated worker thread
const generatePdf = queue.register('generate-pdf', './workers/pdf-worker.js');
// Schedule a recurring task — handler + cron expression in one call
const cleanup = queue.cron('cleanup-sessions', '0 0 * * *', async (job) => {
await db.deleteExpiredSessions(job.data);
});
// Start the polling engine
await queue.start();
// Call the enqueuer from anywhere — fully typed, no raw strings
await sendEmail({ to: 'user@example.com', subject: 'Welcome!' });
await generatePdf({ orderId: 'ord_123' });The string 'send-email' is written once inside register(). The returned function is typed to your payload — no magic strings, no mismatches.
| Step | What Happens |
|---|---|
| Register | queue.register() stores the handler and determines the execution type: I/O (function arg) or CPU (string path arg). Returns a typed enqueuer. |
| Enqueue | Calling the enqueuer writes a row to SQLite with status 'pending' and the execution type ('io' or 'worker'). |
| Poll | queue.start() polls every pollInterval ms, running tryClaimIo() and tryClaimCpu() independently. |
| Claim — I/O | tryClaimIo() claims jobs where type = 'io', gated by the concurrency counter. The handler runs directly on the main thread. |
| Claim — CPU | tryClaimCpu() claims jobs where type = 'worker', gated by the pool's canAccept (idle worker or room to spawn). Dispatched to a generic worker thread. |
| Success | Status shifts to 'completed'. |
| Failure | Status returns to 'pending', attempts increment, run_at bumps with exponential backoff. |
| Dead | After max_retries exhausted, status shifts to 'failed'. |
I/O and CPU jobs never block each other — they use independent concurrency controls.
CPU-bound jobs run in a pool of reusable worker threads. The pool is not coupled to handler paths — any idle worker can dynamically import and run any handler module.
pool.execute(handlerPath, job)
│
├── idle worker? → dispatch(worker, handlerPath, job)
│
├── room to spawn? → spawn() → dispatch(newWorker, handlerPath, job)
│
└── busy + at maxWorkers → queue internally → (dispatched when a worker frees)
When a job completes, the worker is marked idle and the pool drains its internal queue. Excess idle workers above minWorkers are automatically trimmed.
lite-q provides at least once delivery — a job will always run, but in rare cases (crash after execution, before the success is committed) it may retry. This means your handlers must be idempotent: running the same job twice should produce the same result as running it once.
On restart, any job stuck in 'processing' beyond jobTimeout is returned to 'pending' and retried. No job is ever silently lost.
import { LiteQ } from '@km-dev/lite-q';
const queue = new LiteQ({
storagePath: './data/jobs.db',
concurrency: 4, // max concurrent I/O jobs (default: 1)
pollInterval: 500, // ms between DB polls (default: 500)
jobTimeout: 60_000, // ms before a stuck job is released (default: 60000)
minWorkers: 2, // min idle worker threads kept alive (default: 1)
maxWorkers: 4, // max concurrent worker threads (default: 4)
});| Option | Type | Default | Description |
|---|---|---|---|
storagePath |
string |
— | SQLite file path |
concurrency |
number |
1 |
Max concurrent I/O jobs on the main thread |
pollInterval |
number |
500 |
ms between DB polls |
jobTimeout |
number |
60000 |
ms before a stuck 'processing' job is released |
minWorkers |
number |
1 |
Min idle workers to keep alive |
maxWorkers |
number |
4 |
Max concurrent worker threads |
Registers a handler and returns a typed enqueuer function. The job type string lives only here — never repeated.
Use for state-changing operations that must be durable: sending emails, SMS, processing payments, or triggering webhooks. These are tasks where you need at-least-once delivery and built-in retry logic to ensure the work is eventually completed without being "lost" or manually retried.
Because lite-q is at least once, a job may retry if a crash happens after execution but before the success is committed. Pass an idempotency key (e.g. job.id) to the external provider — it skips the work if it already saw that key.
const sendEmail = queue.register<{ email: string; templateId: string }>(
'send-transactional-email',
async (job) => {
// Use job.id as the idempotency key — the provider
// guarantees it only processes this once.
await emailProvider.send(job.data.email, job.data.templateId, {
idempotencyKey: job.id,
});
return { sent: true };
}
);Avoid using this for simple, read-only fetch calls that don't require persistence or crash recovery.
register() detects the function argument and marks this job with type = 'io' in the DB. Concurrency is managed by the concurrency counter — these run on the main thread.
Pass a file path instead of a callback. lite-q detects the string, resolves it to an absolute path, and marks the job with type = 'worker'. It runs in the generic worker pool, keeping the main event loop unblocked.
Write a handler module — a file that exports a default async function. No worker_threads API needed.
const generatePdf = queue.register('generate-pdf', './workers/pdf-worker.js');// workers/pdf-worker.js — runs in an isolated CPU thread
export default async function (job) {
const url = await buildAndUploadPdf(job.data);
return { url };
}Handler modules are dynamically imported by lite-q's generic worker. Any idle thread can run any handler — the pool is not coupled to paths. Throw inside the handler and the error automatically propagates to lite-q's retry logic.
The job object passed to your handler:
| Property | Type | Description |
|---|---|---|
job.id |
string |
UUID |
job.taskType |
string |
The registered type name |
job.data |
T |
Your typed payload |
job.attempts |
number |
How many times this job has run |
job.maxRetries |
number |
Max attempts before permanent failure |
Use the function returned by register().
// Immediate
await sendEmail({ email: 'user@domain.com', templateId: 'welcome_v2' });
// Delayed — run 1 hour from now
await checkTrialExpiry({ userId: 'usr_9011' }, { delay: 60 * 60 * 1000 });
// With custom retry config — retries at 1s → 2s → 4s → 8s → 16s
await syncLedger({ transactionId: 'ch_3Mv1' }, { maxRetries: 5 });
// With priority — runs before lower-priority jobs
await sendAlert(data, { priority: 100 });Options:
| Option | Type | Default | Description |
|---|---|---|---|
delay |
number |
0 |
Milliseconds before the job becomes eligible |
maxRetries |
number |
3 |
Max retry attempts before permanent failure |
priority |
number |
10 |
Higher value = runs first |
lite-q supports persistent recurring schedules alongside one-off jobs. Each schedule stores its cron expression, next run time, and handler binding in SQLite. Every fire creates a separate execution row with timing, status, and error logs — so you get full history without mixing cron runs into the regular job queue.
Cron uses the same I/O vs CPU execution model as register(): pass a function for main-thread I/O work, or a file path for worker-thread CPU work.
Exported types: CronHandle, CronExecution, CronStats, CronJobSummary, CronOptions, CronOptionsFor.
queue.cron() — register a handler and schedule in one call. Best for dedicated recurring tasks.
// I/O handler — runs on the main thread
const cleanup = queue.cron<{ batchSize: number }>(
'cleanup-sessions',
'0 0 * * *', // daily at midnight
async (job) => {
await db.deleteExpiredSessions(job.data.batchSize);
},
{ payload: { batchSize: 500 } },
);
// CPU handler — runs in a worker thread
queue.cron('generate-report', '0 6 * * 1', './workers/report.js', {
payload: { format: 'pdf' },
});queue.schedule() — attach a schedule to a handler already registered via queue.register(). Best when the same handler serves both on-demand enqueues and scheduled runs.
const syncLedger = queue.register('sync-ledger', async (job) => {
await ledger.sync(job.data);
});
// Also run every 6 hours on a schedule
const syncSchedule = queue.schedule('sync-ledger', '0 */6 * * *', {
payload: { source: 'scheduled' },
});
// On demand — dynamic payload per call
await syncLedger({ accountId: 'acc_123' });Expressions are validated at registration time via cron-parser. Standard 5-field and 6-field (with seconds) formats are supported.
| Expression | Meaning |
|---|---|
0 0 * * * |
Daily at midnight |
0 */6 * * * |
Every 6 hours |
0 9 * * 1 |
Mondays at 9:00 |
*/30 * * * * * |
Every 30 seconds (6-field) |
Invalid expressions throw at registration time.
| Option | Type | Default | Description |
|---|---|---|---|
payload |
unknown |
{} |
Static data passed as job.data on every run |
enabled |
boolean |
true |
Set false to register paused |
maxRetries |
number |
3 |
Retries within a single execution (immediate retry loop, not delayed backoff) |
Use CronOptionsFor<T> when you want payload typed against your handler data.
Cron handlers receive the same job shape as regular jobs:
| Property | Type | Description |
|---|---|---|
job.id |
string |
Execution UUID (not the schedule id) |
job.taskType |
string |
The schedule name |
job.data |
T |
Static payload from options.payload |
job.attempts |
number |
How many times this execution has run |
job.maxRetries |
number |
Max attempts before permanent failure |
Both queue.cron() and queue.schedule() return a CronHandle:
const handle = queue.cron('cleanup-sessions', '0 0 * * *', async (job) => { /* ... */ });
// Run immediately — blocks until done, does not advance next_run_at
const execution = await handle.trigger();
// Pause / resume the schedule (in-flight execution continues)
await handle.pause();
await handle.resume();
// Execution history for this schedule
const history = await handle.executions({ limit: 20 });| Step | What happens |
|---|---|
| Register | Schedule row written to lite_q_cron_jobs; next_run_at computed |
| Tick (due) | New execution row created; handler runs |
| Overlap | If already running, skip the run but advance next_run_at |
| Manual trigger | New execution via trigger(); schedule timing unchanged |
| Success / failure | Execution row updated; retries until maxRetries exhausted |
| Pause | No new scheduled runs; in-flight execution continues |
| Stale recovery | Executions stuck in 'processing' beyond jobTimeout are retried or failed |
Cron ticks run in the same poll loop as regular jobs: stale cron recovery → job I/O → cron I/O → job CPU → cron CPU.
- Handlers must be idempotent — lite-q provides at-least-once delivery, and overlap edge cases can skip a scheduled fire while advancing
next_run_at. - Use
payloadfor static config (batch size, report format). For dynamic per-run data, useregister()+ enqueue instead. - Use
trigger()for admin or debug runs. Usepause()before deploys if you need to prevent new scheduled fires. - Re-call
queue.cron()orqueue.schedule()at boot to re-bind handlers after a restart — schedule rows persist in SQLite, but handler functions live in memory.
await queue.start(); // Begin polling — call once at boot
await queue.stop(); // Graceful shutdown — drains pool, finishes in-flight jobs
const stats = await queue.stats();
// { pending: 3, processing: 1, completed: 142, failed: 2, total: 148 }
await queue.purge({ olderThan: 7 * 24 * 60 * 60 * 1000 });
// Removes completed/failed jobs older than 7 daysconst cronStats = await queue.cronStats();
// {
// schedules: 3,
// enabled: 2,
// disabled: 1,
// executions: { pending: 0, processing: 1, completed: 48, failed: 2, total: 51 }
// }
const crons = await queue.listCrons();
// [
// {
// name: 'cleanup-sessions',
// expression: '0 0 * * *',
// type: 'io',
// enabled: true,
// nextRunAt: 1719792000000,
// maxRetries: 3,
// lastStatus: 'completed',
// lastStartedAt: 1719705600000,
// lastCompletedAt: 1719705605123,
// lastDurationMs: 5123,
// lastError: null,
// },
// ...
// ]
const history = await queue.cronExecutions('cleanup-sessions', { limit: 50 });
await queue.purgeCronExecutions({ olderThan: 30 * 24 * 60 * 60 * 1000 });
// Deletes completed/failed executions older than 30 days
// olderThan is a duration in ms (converted to a cutoff timestamp internally)Background jobs run outside the request path — when something breaks, you often find out late. queue.metrics() gives you visibility into what your queue is actually doing: how many jobs are pending, which handlers are failing, whether cron runs are slowing down, and if the worker pool is backing up.
Scrape that output with Prometheus and chart it in Grafana (or any compatible tool). Instead of guessing from logs, you get a live picture of your job logic:
| What you see | What it tells you |
|---|---|
liteq_jobs{status="failed"} rising |
A handler is breaking — check liteq_jobs_by_name to find which one |
liteq_jobs{status="pending"} growing |
Jobs are enqueueing faster than they run — raise concurrency or maxWorkers |
liteq_job_duration_seconds p95 increasing |
A job type is getting slower over time — DB, API, or code regression |
liteq_cron_executions{status="failed"} |
A scheduled task keeps failing — catch it before the next cron fire |
liteq_worker_pool{state="queued"} |
CPU workers are saturated — scale maxWorkers or offload work |
liteq_io_active stuck high |
I/O handlers may be blocking the main thread too long |
That lets you track your queue logic in production, spot regressions after a deploy, and catch problems early — failed emails, stuck PDF generation, a nightly cleanup that never finishes — without manually querying SQLite or tailing logs.
queue.metrics() returns a Prometheus text exposition format string — the standard plain-text format Prometheus scrapes from a /metrics endpoint. lite-q does not start an HTTP server; you mount the endpoint in your own app (Express, Fastify, etc.).
const text = await queue.metrics();
// Returns Prometheus exposition text, e.g.:
// # TYPE liteq_jobs gauge
// liteq_jobs{status="pending",type="io"} 3
// liteq_jobs{status="completed",type="worker"} 142
// ...
// Optional: limit histogram history and customize buckets
await queue.metrics({
windowMs: 24 * 60 * 60 * 1000, // histograms: last 24h only
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60], // seconds
});| Option | Type | Default | Description |
|---|---|---|---|
windowMs |
number |
— | Histograms only: include completed/failed rows with completed_at within the last N ms |
buckets |
number[] |
[0.1, 0.5, 1, 2, 5, 10, 30, 60] |
Histogram bucket upper bounds in seconds |
Metrics emitted:
| Metric | Type | Labels | Description |
|---|---|---|---|
liteq_jobs |
gauge | status, type |
Job counts by status (io / worker) |
liteq_jobs_by_name |
gauge | name, status, type |
Per job-type counts |
liteq_io_active |
gauge | — | I/O jobs running on the main thread right now |
liteq_worker_pool |
gauge | state |
Worker pool: busy, idle, queued |
liteq_job_duration_seconds |
histogram | name, type |
Job duration per type; also aggregated by type only |
liteq_cron_schedules |
gauge | enabled |
Cron schedules (true / false) |
liteq_cron_executions |
gauge | schedule, type, status |
Execution counts per cron schedule |
liteq_cron_duration_seconds |
histogram | schedule, type |
Cron execution duration per schedule |
Mount in your HTTP server:
import express from 'express';
import { queue } from './queue.js';
const app = express();
app.get('/metrics', async (_req, res) => {
res.type('text/plain; version=0.0.4; charset=utf-8');
res.send(await queue.metrics({ windowMs: 7 * 24 * 60 * 60 * 1000 }));
});
app.listen(3000);Docker + Prometheus + Grafana (optional — lite-q itself needs no Docker; this is only if you want charts):
prometheus.yml:
global:
scrape_interval: 15s
scrape_configs:
- job_name: lite-q
static_configs:
- targets: ['app:3000'] # your app's /metrics endpointdocker-compose.yml:
services:
app:
build: .
ports:
- '3000:3000'
volumes:
- ./data:/app/data # persist jobs.db across restarts
prometheus:
image: prom/prometheus:latest
ports:
- '9090:9090'
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- '3001:3000'
environment:
- GF_SECURITY_ADMIN_PASSWORD=adminAdd Prometheus as a Grafana data source (http://prometheus:9090), then build dashboards to visualize queue health over time — for example:
- Failed jobs —
liteq_jobs{status="failed"}or per-handler:liteq_jobs_by_name{status="failed"} - Backlog —
liteq_jobs{status="pending"}to alert when work piles up - Slow handlers —
histogram_quantile(0.95, rate(liteq_job_duration_seconds_bucket[5m]))to see if p95 latency is creeping up - Cron reliability —
liteq_cron_executions{status="failed"}per schedule to catch broken recurring tasks
Set Grafana alerts on these queries (e.g. failed jobs > 0 for 5 minutes) so you are notified before users are affected.
Purge and metrics:
metrics()reads a snapshot of what is currently in SQLite. Counts and histograms grow as jobs complete. Runningpurge()orpurgeCronExecutions()removes old rows from metrics too — totals can go down after a purge, which is expected. If you do not purge, metrics stay consistent and only change when the queue does real work. UsewindowMsto limit histograms to recent history without deleting rows, or pair regular purges withwindowMsso Grafana reflects recent performance.
// queue.ts — create the instance once
import { LiteQ } from '@km-dev/lite-q';
export const queue = new LiteQ({ storagePath: './jobs.db' });// jobs/index.ts — register all handlers, export enqueuers
import { queue } from '../queue.js';
export const sendEmail = queue.register<{ to: string }>(
'send-email',
async (job) => { /* ... */ }
);
export const generateReport = queue.register(
'generate-report',
'./workers/report.js'
);
export const cleanupSessions = queue.cron<{ batchSize: number }>(
'cleanup-sessions',
'0 0 * * *',
async (job) => { /* ... */ },
{ payload: { batchSize: 500 } },
);// main.ts — boot
import { queue } from './queue.js';
import './jobs/index.js'; // registers all handlers
await queue.start();// anywhere in your app
import { sendEmail } from './jobs/index.js';
await sendEmail({ to: 'user@example.com' });| lite-q | BullMQ | Bee-Queue | |
|---|---|---|---|
| Infrastructure required | None | Redis | Redis |
| Persistent jobs | ✅ | ✅ | ❌ |
| Survives crashes | ✅ | ✅ | ❌ |
| CPU thread isolation | ✅ | ❌ | ❌ |
| Delayed scheduling | ✅ | ✅ | ✅ |
| Cron / scheduled jobs | ✅ | ✅ | ❌ |
| Exponential backoff | ✅ | ✅ | ✅ |
| Prometheus metrics | ✅ | ❌ | ❌ |
| Zero runtime deps | ✅ | ❌ | ❌ |
| TypeScript built-in | ✅ | ✅ | ❌ |
| Multi-machine workers | ❌ | ✅ | ✅ |
lite-q is the right choice when you want BullMQ-level reliability without operating Redis. If you need workers across multiple machines, use BullMQ.
lite-q configures SQLite on startup for maximum concurrency and durability:
PRAGMA journal_mode = WAL; -- concurrent readers, single writer
PRAGMA busy_timeout = 5000; -- wait up to 5s on write contention
PRAGMA synchronous = NORMAL; -- crash-safe without full fsync overheadThe type column distinguishes I/O jobs (main thread) from CPU jobs (worker thread), so each claim path queries only its own job type.
CREATE TABLE lite_q_jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL, -- job type name: 'send-email', 'resize-image', etc.
type TEXT NOT NULL, -- execution type: 'io' or 'worker'
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
priority INTEGER DEFAULT 10,
run_at INTEGER NOT NULL, -- epoch ms, eligible run time
locked_at INTEGER, -- set when status = 'processing'
error_log TEXT
);
-- Prevents full table scans during high-frequency polling
CREATE INDEX IF NOT EXISTS idx_lite_q_polling
ON lite_q_jobs (status, type, run_at, priority DESC);Cron schedules and executions use separate tables in the same SQLite file, with the same WAL pragmas:
CREATE TABLE lite_q_cron_jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- schedule name: 'cleanup-sessions', etc.
cron_expression TEXT NOT NULL,
type TEXT NOT NULL, -- execution type: 'io' or 'worker'
payload TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
max_retries INTEGER NOT NULL DEFAULT 3,
next_run_at INTEGER NOT NULL, -- epoch ms, next scheduled fire
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE lite_q_cron_executions (
id TEXT PRIMARY KEY,
cron_job_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
started_at INTEGER,
completed_at INTEGER,
duration_ms INTEGER,
error_log TEXT,
FOREIGN KEY (cron_job_id) REFERENCES lite_q_cron_jobs(id)
);
CREATE INDEX IF NOT EXISTS idx_lite_q_cron_due
ON lite_q_cron_jobs (enabled, next_run_at);
CREATE INDEX IF NOT EXISTS idx_lite_q_cron_executions_job
ON lite_q_cron_executions (cron_job_id, started_at DESC);SQLite WAL persistence · Atomic job locking · I/O + CPU concurrency separation · Generic worker pool with minWorkers/maxWorkers lifecycle · Exponential backoff · Delayed scheduling · Priority queues · Graceful shutdown · Handler modules (no worker_threads boilerplate)
queue.cron() and queue.schedule() · Persistent cron expressions · Per-run execution history · CronHandle (trigger, pause, resume) · Overlap skip · cronStats(), listCrons(), cronExecutions(), purgeCronExecutions()
queue.metrics() · Prometheus text exposition format · Job gauges and duration histograms (io / worker) · Cron execution counts and duration histograms per schedule · windowMs and custom histogram buckets
CLI inspect command · Optional embedded HTML dashboard · Layer 2 tooling on top of metrics data
Does this work with multiple processes?
Yes. WAL mode supports concurrent readers and BEGIN IMMEDIATE TRANSACTION ensures no two processes ever claim the same job, even across separate OS processes on the same machine.
What happens if my app crashes mid-job?
Any job stuck in 'processing' beyond jobTimeout is automatically returned to 'pending' on the next restart. Because the success may or may not have been committed before the crash, lite-q provides at least once delivery — your handler should use an idempotency key (e.g. job.id) to detect and skip duplicates.
When should I use BullMQ instead? When you need workers distributed across multiple machines, or throughput above tens of thousands of jobs per second. lite-q is intentionally scoped to single-node deployments.
Is TypeScript required?
No — works with plain JavaScript too. TypeScript types are bundled; no separate @types package needed.
When should I use cron vs enqueue?
Use queue.cron() or queue.schedule() for recurring tasks with a static payload (config, batch size, report format). Use register() + enqueue for one-off jobs with dynamic data per call (user id, order id, etc.).
What if a cron run takes longer than the interval?
lite-q allows only one execution per schedule at a time. If a run is still 'processing' when the next tick fires, the scheduled fire is skipped but next_run_at is advanced — no overlapping runs pile up.
Do cron schedules survive restarts?
Yes. Schedule rows persist in SQLite. Handler functions live in memory, so re-call queue.cron() or queue.schedule() at boot to re-bind them (same as register()).
How do metrics relate to purge?
queue.metrics() reflects whatever is stored in SQLite at scrape time. purge() removes old completed/failed jobs from counts and duration histograms. purgeCronExecutions() does the same for cron execution history. Without purging, metrics only increase (or stay flat when idle). Use windowMs if you want histograms scoped to recent activity without deleting rows.
-
Source: github.com/iikareem/liteQ
-
Issues: github.com/iikareem/liteQ/issues
MIT © lite-q Contributors