Slashing serverless API latency with AppSync JavaScript resolvers

Rawad Haber
Level Up Coding
Published in
6 min readJan 3, 2023

--

If you’ve ever written a serverless function, chances are you’ve encountered the ‘cold start’ problem.

The way serverless compute services such as AWS Lambda work is you provide configurations such as the runtime, memory allocation, and region; and the cloud provider takes care of running the code you provide. Cloud providers do that by spinning up compute resources to fulfill a given request, then spinning them down after a few minutes.

The process of spinning up these on-demand compute resources and bootstrapping them is known as a cold start. It can usually add about a second or two to a given request, depending on a lot of factors such as bundle size, runtime, and memory allocation.

The Lambda benefits

Serverless compute brings a lot of benefits to the table. Starting with not needing to manage and maintain servers. No more web server 001 went boom and nobody knows why!

Onto to possibly an even bigger benefit which is scaling. Without needing to configure anything, and depending on region AWS lambda can scale to thousands of concurrent requests per second. That is huge and shifts all scaling responsibility onto the cloud provider.

The third big benefit for me is the ability to scale down to zero. For me, this is an essential tenant in defining what is serverless from what isn’t. Scaling down to zero means you only pay when fulfilling a request and outside of that, there’s no minimum payment whatsoever.

On a side note, this disqualifies services such as Amazon Aurora Serverless and Serverless OpeanSearch from being serverless. I believe the accurate terminology for those services is autoscaling services more so than serverless.

The latency problem

Latency historically has never been the serverless compute strong suit. Even with what I’m about to share with you here, should single-digit ms latency be a business requirement, containers and container orchestration such as Amazon ECS or Amazon EKS is what I would explore.

For synchronous services such as REST or GraphQL APIs, latency will always be important, granted to a varying degree.

Solving our own latency problems

We at Orbital use Auth0 for authenticating our users and for making secure calls to our backend services.

When the product was first built, the engineering team opted for a Rest API using API Gateway with lambda, DynamoDB, and EventBridge. Alongside a lambda custom authorizer to validate and authorize incoming JWTs, which made sense at the time.

That naturally causes a much higher cold start, around 3–4 seconds, and a higher latency overall.

Enter AppSync and JavaScript Pipeline Resolvers

Towards the end of last year, I started looking at the possibility of using some AppSync features, to reduce latency but also to take the opportunity to introduce our team to GraphQL.

AppSync is the AWS-managed GraphQL service, that essentially allows you to define GraphQL schemas and resolve fields in that schema in a variety of different ways, one of which is direct JavaScript resolvers. You can think of a JavaScript resolver as a script that runs when a field is requested through the GraphQL endpoint.

I love everything about GraphQL and much prefer its declarative, strongly typed style to REST.

Removing the lambda custom authorizer

The first step was to remove and replace the lambda custom authorizer, as opposed to just moving it to AppSync which would still cause the latency problem.

Enter AppSync OIDC integration!

Absolutely love this feature, essentially a couple of lines of YAML in your AppSync configuration, and now API calls are authorized against a given OpenID Connect endpoint. It was Auth0 in our case but it can be anything really that is compliant with the spec.

Here is what this looks like using the serverless framework V3 and the AppSync plugin (using version 2.0.0-alpha.13):

appSync:
name: ${self:service}-${opt:stage, self:custom.stage}
logging:
level: ALL
retentionInDays: 14
authentication:
type: OPENID_CONNECT
config:
issuer: https://prototype.auth0.com
clientId: someID

Once this is in place, the decoded access token becomes now part of the request and is available for resolvers through the context object in the identity property.

This is wild, most of what our lambda custom Authorizer did was now replaced with a couple of lines of YAML!

Adding Authorization

Okay so now we’ve taken care of authentication, i.e who can access our API and we now needed to make sure a given authenticated user has the right access to resolve a given field in the GraphQL schema.

We evaluated some options and given that our GraphQL APIs weren’t a big monolith with a lot of fields to resolve, we decided to include the permissions within the access token. That did work for us, but might not necessarily be the answer to every authorization problem, especially if the API is too big with a lot of fields. There is a limit on JWT size, so be sure to keep that in mind.

So the idea is simple here: Check the permissions property of the decoded JWT, compare it against the field a given user is trying to resolve, and authorize the request if all checks out.

Here is what that resolver looks like:

import { util } from '@aws-appsync/utils'

export function request(ctx) {
const { permissions } = ctx.identity.claims
const { fieldName } = ctx.info
const allowed = validatePermission(permissions, fieldName)
if (!allowed) {
util.unauthorized()
}
return {
payload: ctx.args,
}
}

export function response(ctx) {
return ctx.result
}

function validatePermission(permissions, fieldName) {
let allowed = false
for (const permission of permissions) {
if (permission === fieldName) allowed = true
}
return allowed
}

Pipeline Resolvers with JS

Pipeline resolvers in AppSync are a pretty nifty concept. The idea is that you can chain a few resolvers together to fulfill a given request.

The biggest advantage in my opinion is logic re-use and keeping our request handling DRY. So the authorizer logic from earlier can be re-used in every API request!

Here is what that looks like in serverless framework for a getOrgById Query field:

  schema: schema.graphql
dataSources:
mainTable:
type: AMAZON_DYNAMODB
config:
tableName: ${self:custom.tableName}
iamRoleStatements:
- Effect: 'Allow'
Action:
- dynamodb:GetItem
Resource:
- arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/${self:custom.tableName}
auth:
type: NONE
resolvers:
Query.getOrgById:
functions:
- dataSource: auth
code: src/resolvers/auth.js
- dataSource: mainTable
code: src/resolvers/getOrgById.js

getOrgById might look something like this in JS:

import { util } from '@aws-appsync/utils'

export function request(ctx) {
return dynamoDBGetItemRequest({ pk: `ORG#${ctx.prev.result.orgId}`, sk: `ORG#${ctx.prev.result.orgId}` })
}

export function response(ctx) {
return ctx.result
}

function dynamoDBGetItemRequest(key) {
return {
operation: 'GetItem',
key: util.dynamodb.toMapValues(key),
}
}

Conclusion

By utilizing a host of AppSync features such as OIDC integration as well as Javascript direct resolvers, we were able to slash our cold starts completely. Remember, direct field resolvers don’t incur cold starts at all!

Latency went down even further by replacing some lambda functions with JavaScript resolvers that directly integrate with DynamoDB.

Now there’s a caveat to this that you should keep in mind: Not all features of JavaScript can be used with direct resolvers.

No libraries can be imported and most JS array methods are not available. Meaning a lot of for of loops, but given the benefits, the tradeoff was a no-brainer for us!

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--