Lambdaliths are not only for APIs
I am a big fan of monolithic Lambda functions, also known as Lambdaliths. When I build APIs, I usually start with a Hono app in a single Lambda function behind an API Gateway. There are many valid arguments both for and against this approach. If you ask me, the benefits outweigh the drawbacks. Some of the main benefits I see are:
- Faster deployments: Deploying one function is quicker than deploying ten.
- Less infrastructure code: Whether it’s SST, CDK, or SAM, one function requires less code than ten.
- Portability: The Hono app can also run in a container.
- Familiarity: If you’ve worked with Express-like APIs before, modern frameworks like Hono will feel familiar.
- Less boilerplate: There’s less boilerplate going around.
So, my APIs usually consist of a single Lambda function. But what about everything else? There’s usually a smorgasbord of Lambda functions handling events from SQS, DynamoDB, SNS, EventBridge, and so on.
So I thought, why don’t I send all asynchronous stuff to the same function and use a router to route to the correct business logic? I wanted a router that could route based on event sources, such as a specific SQS queue, rather than on HTTP verbs and paths.
I couldn’t find a router/library/framework that did just that, so I created one. I was also happily surprised that the fitting name lambdalith was available on npm.
A word of caution
It’s still early and the library has not been battle-tested in production, yet. The API may change slightly before a stable 1.0 release.
But why?
One of the things I find hardest when working with larger serverless applications is getting a clear overview. To find out where a specific EventBridge event is handled, you’d dive into the infrastructure code, find the specific rule, go to the target Lambda, and find out which source file its handler lives in.
Every new event handler requires the same dance. Create a new function in the infrastructure code, wire it up, and add the necessary boilerplate code to parse, validate, handle batching, etc. The powertools libraries help by reducing boilerplate, but each new function still requires some setup.
With a Lambdalith router, you’d have one place to check. The handler code is the map of your application. What do we do with OrderCreated events? Check the handler. Do we do anything with the OrdersTable stream? Check the handler.
I wanted to see if I could go from this:
to this:
The EventRouter
For reference, a simple Hono app could look something like:
const app = new Hono();
app.post("/users", (c) => { ... })app.get("/users", (c) => { ... })app.get("/users/:id", (c) => { ... })app.delete("/users/:id", (c) => { ... })I wanted something similar, but for non-HTTP events. An API that is clean, concise, and fully typed. Instead of verbs and paths, it handles different event sources:
const router = new EventRouter();
router.sqs('OrdersQueue', (c) => {});router.sns('OrdersTopic', (c) => {});router.dynamodb('OrdersTable', (c) => {});router.event({source: "OrderService", detailType: "OrderCreated"}, (c) => {});
export const handler = router.handler()Let’s dive in.
Routing
The router initially supports the following event sources:
- SQS: Route based on queue name.
- SNS: Route based on topic name.
- DynamoDB: Route based on table name.
- EventBridge: Route based on
source,detailType, or both.
Each event source also supports wildcard routes:
router.sqs('OrdersQueue', (c) => { // Will handle all messages from OrdersQueue});
router.sqs((c) => { // Will handle all other SQS messages});On each invocation, the router inspects the payload to identify the event source. It then matches against registered routes and invokes the first matching route. Routes are evaluated in registration order. If no specific or wildcard route is found, it looks for a notFound handler. If found, it invokes that. Otherwise, the record is ignored (marked as successfully processed).
Context
Each handler receives a fully typed context object that includes the Lambda context and event source-specific context, such as messageId for SNS and SQS, and eventType for DynamoDB streams.
The actual payloads (SQS body, DynamoDB newImage, EventBridge detail, etc.) are typed as unknown. Parse them inside the handler with your favorite tool, such as Zod or Typebox.
const OrdersSchema = z.object({ ... })
router.sqs('OrdersQueue', (c) => { const messageId = c.sqs.messageId const attributes = c.sqs.attributes const body = OrdersSchema.parse(c.sqs.body)})Batch Processing
The router supports batch processing for SQS and DynamoDB out of the box. It handles returning partial batch responses to the Lambda service. You must enable ReportBatchItemFailures on your event source mappings for the router to play nice.
The router supports both parallel and sequential processing. By default, records are processed in parallel. If you must process records in order, such as when consuming a FIFO SQS Queue, you can switch to sequential processing:
router.sqs('OrdersQueue.fifo', (c) => { ... }, { sequential: true });When processing records sequentially, the router will stop on the first failing record. It will mark it and subsequent records as failed in the batch.
Error handling
The router handles the necessary error handling. It catches errors thrown from your handler(s) and handles them appropriately. In batch processing, this means the router returns (a successful response with) the list of failed records. For EventBridge and SNS, the error is propagated, causing the Lambda invocation to fail, allowing the respective service to retry it as needed.
The router allows you to define a (global) error handler. Here, you can decide to “swallow” specific errors. By not rethrowing an error in the handler, you effectively tell the router to treat that record as successfully processed. This can be useful when poison pill records have entered your system, as it makes no sense to retry such records.
router.onError((error, c) => { if (error instanceof SomeRetryableError) { // Mark as failed so Lambda can retry or send to DLQ. throw error; }});Middleware
The router has built-in support for middlewares, both global and per event source:
router.use(async (c, next) => { console.log('before'); await next(); console.log('after');});
router.use('sqs', async (c, next) => { console.log('before sqs'); await next(); console.log('after sqs');});Middlewares run in the order that they are registered. The above sample results in "before" → "before sqs" → handler → "after sqs" → "after". You have access to the fully typed context in the middleware, and you can use the get/set method on the context to store arbitrary data that is made available in the handler.
Full documentation
Check out GitHub or npm for more details.
But what about?
Cold starts?
Ah, the million-dollar question. The router itself is tiny with zero dependencies. However, if your function grows to 100s of handlers, the total code size will obviously increase, which will lead to longer cold starts. That said, the router handles asynchronous events. Cold starts are more noticable in synchronous APIs, where a user waits for instant feedback on the other side. Also, by routing all events to a single Lambda function, there will likely be fewer cold starts overall, since all invocations will share the same pool of warm execution environments.
What if some events need a bigger box?
So you’ve created a router, given it 1024 MB of memory, added a bunch of event sources, and called it a day. Then, you’ve realised that the processing of some events would benefit from cranking the memory up to 3072 MB. But increasing it would affect the processing of all events, resulting in a significant increase in the bill.
Luckily, there are escape hatches. Create a second Lambda function with the same handler and set it to 3072 MB, while keeping the original at 1024 MB. Move the resource-hungry event source mapping to the new one. There’s no need to rewrite any router or business logic.
Hundreds of processors in a single handler?
There may come a point where one handler cannot fit everything. Then, slice it up. Move all EventBridge handlers to one function, and SQS handlers to another. Or slice it by service. You do you!
But is it production-ready?
As we say in Sweden: “Ingen minns en fegis! (No one remembers a coward)“
Try it out
Please give it a spin and let me know what you think. Feedback is most welcome!
npm i lambdalith