Modern serverless job schedulers
9/28/2022 · 5 min read
Almost all developers will use job scheduler for their app at some point, without being impressed by it. That's because job schedulers haven't changed much in the last 10 years, and the APIs are still similar to how they were when (trigger warning!) IE10 was released.
Why is this a big deal?
Right now, the world cares about latency. A job scheduler makes your app faster and more reliable by keeping the critical path of your API as clean as possible. It’s common to move all non-critical API work into the background via a job scheduler.
For example, when you handle a webhook from Zoom you need to respond within 3 seconds. You need to accept the payload then return a 200 as quickly as possible by pushing the payload to a queue. The same pattern applies to your registration APIs, rate-limiting (to temporarily ban accounts outside of the limiter), or any other business logic in your systems.
Unfortunately, most the tooling around most job schedulers is a decade or two old. You construct individual untyped queues and push jobs on via RPC or individual calls. There's no type checking, no way to version functions, and no way to locally replay failed events. Observability, metrics, scaling? That’s left to you.
There's so much room for improvement here. We've been working on a whole new approach to make this easier and faster for developers.
Inngest: replacing job schedulers with events
Inngest uses events instead of RPC to trigger functions. Instead of creating, configuring, and calling individual queues, you send over a single event which indicates that "something happened in your system". All of the necessary background functions automatically run in parallel, without you specifically calling them. It's classic decoupling: we invert control from the caller to the scheduler. Now you only need to trigger an event, which makes writing code much simpler and faster.
Why is this better? Well, there are a lot of reasons:
- Code is easier to write, read, debug, and maintain, as your system sends events instead of calling many jobs with similar-but-slightly-alternating arguments at once
- Events are fully typed, so it's easy to write new background work that adds functionality to your app
- Events are also versioned, so you can refactor and update your code safely. This also helps solve coordinated rollouts of applications and workers.
- You don't have to create new workers which subscribe to each queue, so deploying and operations becomes much easier.
- We store all of the events you send, giving you full observability and allowing you test locally via local replay
- You can also forward the events over to your data warehouse, acting as an audit trail and data source for your business
If this sounds similar to a regular publish/subscribe model, that's because it is similar. Inngest blends pub/sub with queues, retries, idempotency, throttling, and step functions — all things you'd expect from a traditional job scheduler — while adding best practices for events, such as types and versions. This is how a modern scheduling API should work.
Declarative functions for background work and scheduled jobs
Using an event-driven paradigm, you can make your background jobs declarative. Each function can choose the events that trigger it, or its schedule to run on. Here’s an example:
// @filename: ../__generated__/inngest.tsexport type AuthSignup = {name: "auth/signup";data: {user_id: string;email: string;account_id: string;plan: string;};user: {id: string;email: string;};v?: string;ts?: number;};// @filename: ../lib/index.tsimport { AuthSignup } from '../__generated__/inngest';export const addToStripe = async (user_id: string) => {};export const sendWelcomeEmail = async (event: AuthSignup) => {};// @filename: index.ts// ---cut---import { createFunction } from "inngest";// Import the type for the event you want to listen to. This fully types// the arguments to your function. The types are generated by running// `npx inngest-cli types ts`.import { AuthSignup } from '../__generated__/inngest';import { addToStripe, sendWelcomeEmail } from '../lib/';export const newPR = createFunction<AuthSignup>("New PR", "auth/signup", async ({ event }) => {// This function is triggered any time the `auth/signup` event is received.await addToStripe(event.data.user_id);await sendWelcomeEmail(event);});
This makes understanding your system much easier: you won't have to go on a yak shave to understand when a particular job is called, as it specifies the event up front.
Serverless: the modern architecture
There's a lot to be said for serverless platforms. They help you build much faster, and they’re easier to deploy to and operate.
A core problem with job queues is their stateful nature. Things like Celery, Sidekiq, and Bull all require long-running workers that subscribe to queues to run jobs. You need to buy long-running VMs and predict your scale so that you can handle peak load. And the queues themselves live on services like Redis or RabbitMQ. It's expensive and wasteful, and it's operationally complex.
Taking our inversion of control principle for events, an ideal job scheduler would call you when functions need to run. You could then write functions that deploy to any platform (eg. Vercel, Lambda, Netlify, Cloudflare, Kubernetes or Nomad — it doesn't matter).
This is exactly how Inngest works. We maintain the messaging systems, queues, and state, and call your functions whenever they need to run. It's both cheaper and easier to manage than hosting stateful servers and Redis or RabbitMQ yourself.
Try it out
You can try out Inngest for free in any of your projects. We're also happy to announce our Typescript SDK, which lets you create strongly typed serverless functions which can be hosted on your serverless platform of choice. It only takes a single line of code to create a background function. We'd be happy if you played around with it!