LearnWithShobhan

What belongs outside the Lambda handler? Reuse clients, avoid the shared-state bug

Shobhan Biswas··5 min read
>_From the course VidShare: 109 lessons, lifetime access.

Every Lambda tutorial puts the code in one function called handler. Sooner or later you wonder: should this new Something() go inside the handler or above it? Put it in the wrong place and you get one of two problems: a slower function, or a bug that only appears on the second request and is gone after a deploy.

This post explains what actually happens, with a real example of the bug.

What "warm" means

When a request arrives and no execution environment is ready, Lambda creates one (a cold start). It loads your file, and runs everything at the top level of the module: imports, constants, new calls. That is the init phase and it happens once per environment.

Then Lambda calls your handler. When the request ends, the environment is not thrown away. If another request comes soon, Lambda sends it to the same environment, and calls handler again without running the init code again.

One Lambda execution environment running init once, then three requests. An object created at init collects resolutions and has 2, then 4, then 6 entries
Module-level objects live as long as the environment does. Every request sees the same ones.

Two consequences follow from that:

  1. Anything created outside the handler is reused. That is a performance gift: set it up once, use it for hundreds of requests.
  2. Anything created outside the handler is also shared by those requests. If it keeps state, the state leaks from one request into the next.

(One environment handles one request at a time, so you do not need locks. The problem is not concurrency, it is leftover state.)

The bug, step by step

The VidShare project has a VideoConverter that collects the resolutions a video should be converted into, then converts. Here it is, with the object created at the top of the file:

// video-converter.ts
export class VideoConverter {
  private resolutions: string[] = [];
 
  addResolution(resolution: string) {
    this.resolutions.push(resolution);
  }
 
  async convert() {
    console.log(JSON.stringify(this.resolutions, null, 2));
  }
}
// s3-event-listener.ts  (the buggy version)
const videoConverter = new VideoConverter();      // runs ONCE per container
 
export const handler = async (event: S3Event) => {
  const { width } = await getMetadata(event);
  if (width >= 1280) videoConverter.addResolution("1280x720");
  videoConverter.addResolution("640x360");
 
  await videoConverter.convert();
};

Run the handler twice in the same environment, and the logs show:

first call:   [ "1280x720", "640x360" ]
second call:  [ "1280x720", "640x360", "1280x720", "640x360" ]

The second video is converted into four outputs, two of them duplicates, even though a video can have at most two. Nothing in the second request's code changed. The videoConverter object was created in the init phase, and resolutions.push added to the same array again.

The authors of the course found it while writing a test: Jest loads the module once per test file and then calls the handler many times, which is the same thing a warm Lambda does. It is a good reminder that a bug that "only happens in the cloud" often shows up in your test first.

The fix: create stateful objects inside the handler

export const handler = async (event: S3Event) => {
  const videoConverter = new VideoConverter();   // a fresh one per request
 
  const { width } = await getMetadata(event);
  if (width >= 1280) videoConverter.addResolution("1280x720");
  videoConverter.addResolution("640x360");
 
  await videoConverter.convert();
};

Now every request starts with an empty list, and the output is always [ "1280x720", "640x360" ].

Which objects go where?

The answer is not "everything inside" either. Rebuilding expensive objects on every request wastes time. Decide object by object:

Create outside the handler (once)Create inside the handler (every request)
AWS SDK clients (DynamoDB, S3, ...)Objects that collect data during a request
Database connections you want to reuseBuilders, converters, accumulators
Parsed configuration and constantsArrays or counters that you mutate
Compiled regular expressions, loaded schemasAnything that holds the current user or request
Large static data loaded from a fileRequest-specific IDs and timestamps

A useful test: if the object holds nothing about one particular request, and building it costs time, it belongs outside. If it changes while a request is processed, it belongs inside.

In the VidShare handler, that gives:

// Outside: stateless, reused
const videoDb = new VideoDb(process.env.VIDEO_TABLE_NAME!);
const uploadBucket = new UploadBucket(process.env.UPLOAD_BUCKET_NAME!);
const videoMetadata = new VideoMetadata(MEDIAINFO_PATH);
 
export const handler = async (event: S3Event) => {
  // Inside: collects state for this request only
  const videoConverter = new VideoConverter();
  ...
};

The database client keeps its connection settings and can be reused, the bucket helper only wraps a name, and the metadata reader only knows where the mediainfo binary is. None of them remembers anything about one request.

Related traps

  • Never keep request data in a module-level variable. Caching "the current user" at the top level means the next request, possibly from a different user, can read it.
  • Lambda's /tmp folder also survives between invocations in the same environment. Clean up files you create, or use unique names.
  • Init code counts toward cold-start time. Loading a huge library or building a big object outside the handler slows the first request in each new environment. Create expensive things outside, but load only what you need.
  • Test with the same pattern. In Jest, import the handler once and call it twice. If the second result differs from the first, you have shared state. Call jest.resetModules() between tests when you do want a clean module.

Key takeaways

  • ✓Lambda runs your top-level code once per environment, then calls the handler for each request, reusing the environment while it is warm.
  • ✓Objects created outside the handler are reused: ideal for SDK clients and config.
  • ✓The same objects are shared across requests, so anything that accumulates state belongs inside the handler.
  • ✓A symptom is results that grow or repeat on the second call. Your tests can reproduce it by calling the handler twice.
  • ✓Never store request or user data in module-level variables.

See it built, tested and deployed

VidShare: Build a Video Streaming Platform on AWS builds a complete serverless backend, test first, so bugs like this one show up on your laptop and not in production. 109 lessons covering API Gateway, Lambda, DynamoDB, S3, MediaConvert, OpenSearch and CDK. Lifetime access, and three lessons are free to watch.

Free download

Linux command line cheat sheet

One printable page: navigation, files, permissions, pipes, processes, scripts and SSH.

Occasional emails with new tutorials and courses. Unsubscribe any time.

Keep reading