Tickless 1.0.0
dotnet add package Tickless --version 1.0.0
NuGet\Install-Package Tickless -Version 1.0.0
<PackageReference Include="Tickless" Version="1.0.0" />
<PackageVersion Include="Tickless" Version="1.0.0" />
<PackageReference Include="Tickless" />
paket add Tickless --version 1.0.0
#r "nuget: Tickless, 1.0.0"
#:package Tickless@1.0.0
#addin nuget:?package=Tickless&version=1.0.0
#tool nuget:?package=Tickless&version=1.0.0
Tickless
An event-driven, in-process job scheduler for .NET that owns no tables.
📑 Table of Contents
- 💡 The Idea
- 📦 Install
- ⚡ Quick Start
- 🔁 How Recovery Works
- 🔍 Inspecting Scheduled Jobs
- ⚖️ Comparison
- 🚫 When NOT to Use This
- 🎬 Demo
- 📁 Project Structure
- 🛠️ Building from Source
- 📄 License
💡 The Idea
Most job schedulers solve durability by adding their own tables. A job table, a state table, a queue table, plus a few more on top. Tickless takes a different route. The deadline you care about almost always already lives in your domain: TrialExpiresAtUtc, PublishAtUtc, CooldownExpiresAtUtc, and so on. On startup, each handler queries that domain state and tells the scheduler what is still outstanding. Tickless rehydrates its timers from there. There is nothing extra to migrate, nothing extra to back up, and nothing else to keep in sync.
In short: your domain state is the queue.
📦 Install
dotnet add package Tickless
Targets net10.0. The package brings in only the abstractions for hosting, DI, and logging, so it adds no infrastructure dependencies of its own.
⚡ Quick Start
1. Register Tickless and your handler
// Program.cs
builder.Services.AddDbContext<BlogContext>(o => o.UseSqlite("Data Source=blog.db"));
builder.Services.AddTickless();
builder.Services.AddSingleton<IJobHandler, PublishPostJobHandler>();
2. Implement the handler
public class PublishPostJobHandler(IServiceProvider services) : IJobHandler
{
public string JobType => "PublishPost";
public async Task ExecuteAsync(string? payload)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BlogContext>();
var post = await db.Posts.FindAsync(Guid.Parse(payload!));
post!.Status = "Published";
await db.SaveChangesAsync();
}
public async Task<IEnumerable<ScheduledJob>> RecoverPendingJobsAsync()
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BlogContext>();
return (await db.Posts.Where(p => p.Status == "Scheduled").ToListAsync())
.Select(p => new ScheduledJob($"publish-{p.Id}", p.PublishAtUtc, p.Id.ToString()));
}
}
3. Schedule work
Anywhere you have IJobScheduler injected:
await scheduler.ScheduleAsync(
jobId: $"publish-{post.Id}",
executeAtUtc: post.PublishAtUtc,
jobType: "PublishPost",
payload: post.Id.ToString());
That is the whole API surface for scheduling. Cancel by id with CancelAsync(jobId); replacement is idempotent (re-scheduling the same id cancels the previous timer).
🔁 How Recovery Works
When the host starts, Tickless calls RecoverPendingJobsAsync on every registered handler. Each handler returns the work it knows is still outstanding, derived from a query against its own domain tables. Tickless rearms timers for those jobs and resumes.
If the process dies while jobs are armed, restarting it re-runs the same query and the timers come back. There is no in-flight state to corrupt, because there is no in-flight state at all. Only the domain rows you were already saving.
🔍 Inspecting Scheduled Jobs
app.MapGet("/scheduler/jobs", (IJobScheduler scheduler) => scheduler.GetScheduled());
GetScheduled() returns a snapshot of currently armed timers as IReadOnlyCollection<ScheduledJobView> (JobId, ExecuteAtUtc, JobType). Payloads are intentionally omitted so this is safe to wire to an admin endpoint.
⚖️ Comparison
| Tickless | Hangfire | Quartz.NET | Coravel | Raw BackgroundService | |
|---|---|---|---|---|---|
| Owns a database schema | No | Yes (many) | Yes | Yes | No |
| Polls between jobs | No | Yes | Yes | Yes | Up to you |
| Recovers across restarts | Yes (your domain) | Yes | Yes | Yes | No |
| Multi-instance / scale-out | No | Yes | Yes | No | No |
| Built-in inspection | Endpoint | Full UI | Limited | Limited | None |
| External infrastructure | None | Optional Redis | Optional cluster | None | None |
🚫 When NOT to Use This
- You run more than one host instance. Two replicas will each fire every job. Tickless has no leader election or distributed locking. Reach for Hangfire or Quartz, or a queue-backed scheduler (Azure Service Bus or AWS SQS scheduled messages) for horizontally scaled deployments.
- You need sub-second precision.
Task.Delayis best-effort under thread-pool starvation and GC pauses. Accuracy at human scale (seconds, minutes, days) is fine. Real-time systems are not. - You need cross-service messaging. Tickless schedules work for a handler in the same process. If service A schedules work that should run on service B, use a message bus.
- Your deadlines do not exist as durable rows. The whole pattern hinges on each handler being able to ask "what is pending?" against your domain. Without a domain row backing each job, recovery has nothing to read.
🎬 Demo
A runnable sample lives in samples/Tickless.Sample.BlogPublishing - a minimal API with SQLite and a Scalar UI. Schedule a post for 60 seconds out, hit Ctrl+C mid-wait, restart, watch the timer rearm and the post publish on time.
dotnet run --project samples/Tickless.Sample.BlogPublishing
Then open http://localhost:5009/scalar/v1. The Scalar page doubles as the demo's introduction, with a "Try it in 60 seconds" walkthrough that exercises the recovery path directly from the browser.
📁 Project Structure
Tickless/
src/
Tickless/ The package
IJobScheduler.cs Public contract: ScheduleAsync, CancelAsync, GetScheduled
IJobHandler.cs Per-job-type contract: ExecuteAsync, RecoverPendingJobsAsync
ScheduledJob.cs Recovery DTO returned from RecoverPendingJobsAsync
ScheduledJobView.cs Read-only inspection record returned from GetScheduled
InProcessJobScheduler.cs ConcurrentDictionary + Task.Delay implementation
ServiceCollectionExtensions.cs AddTickless() DI helper
samples/
Tickless.Sample.BlogPublishing/ Minimal API demo (SQLite + EF Core + Scalar UI)
tests/
Tickless.Tests/ xUnit + AwesomeAssertions (18 tests)
.github/workflows/
ci.yml Build + test on push and PR
release.yml Pack + publish on v* tags
🛠️ Building from Source
Requires the .NET 10 SDK.
git clone https://github.com/blaisedegier/Tickless.git
cd Tickless
dotnet build
dotnet test
To produce the NuGet package locally:
dotnet pack src/Tickless/Tickless.csproj -c Release -o ./artifacts
The release pipeline is tag-driven. Push a tag matching v* (e.g. v1.0.1) and GitHub Actions builds, tests, packs with the version derived from the tag, and pushes to NuGet. The repo secret NUGET_API_KEY must be set.
📄 License
MIT - see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.7)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.7)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 87 | 5/11/2026 |