Type something to search...
MongoDB Atlas Triggers and Functions: Serverless Backend Logic

MongoDB Atlas Triggers and Functions: Serverless Backend Logic

A lot of backend code isn't really part of a request. When an order is placed, you want to send a confirmation email. When a user profile changes, you want to update a denormalized copy in another collection. Every night, you want to roll up yesterday's metrics. You can build all of that with a worker process, a message queue, and a cron job, but then you're maintaining three more pieces of infrastructure for what amounts to a few dozen lines of logic.

Atlas Triggers run server-side JavaScript Functions in response to events: a document being inserted, updated, replaced, or deleted, or a schedule firing. They're built on change streams, run inside Atlas, and scale without you provisioning anything. For event-driven glue code, they're one of the most useful features in Atlas.

This guide covers the trigger types, how to write functions that work with the context object, practical patterns like denormalization and scheduled rollups, how to handle errors and ordering, and the limits you should know before you lean on triggers for critical work.

A Note on What's Still Supported

Triggers and Functions historically lived inside Atlas App Services, alongside the Data API, HTTPS Endpoints, Device Sync, and GraphQL. MongoDB deprecated several of those App Services features, and the Data API, HTTPS Endpoints, and Device Sync reached end of life in September 2025. Database and scheduled Triggers are still supported and are managed directly from the Triggers page in the Atlas UI, the Atlas CLI, or the Admin API.

So if you find older tutorials that expose a Function as an HTTPS endpoint or call it through the Data API, don't build on that pattern. Use Functions as trigger handlers, and put request-response APIs in your own application code or a regular serverless platform.

Trigger Types

There are two trigger types you'll use for most backend work:

Trigger typeFires whenTypical use
DatabaseA change event occurs on a watched collectionDenormalization, notifications, audit logs, syncing
ScheduledA CRON schedule firesRollups, cleanup jobs, reports, periodic syncs

Authentication triggers also exist for applications that use App Services authentication, but they're tied to that user system and aren't relevant to most backends.

Your First Database Trigger

Let's say you have an orders collection and you want to send a confirmation whenever a new order is inserted.

In the Atlas UI, go to Triggers, click Add Trigger, and configure:

  • Trigger type: Database
  • Cluster / database / collection: Prod / shop / orders
  • Operation type: Insert
  • Full Document: On
  • Event type: Function

Then write the function:

exports = async function (changeEvent) {
  const order = changeEvent.fullDocument;

  const db = context.services.get("mongodb-atlas").db("shop");
  const customer = await db
    .collection("customers")
    .findOne({ _id: order.customerId }, { projection: { email: 1, name: 1 } });

  if (!customer) {
    console.log(`No customer found for order ${order._id}`);
    return;
  }

  await db.collection("outbox").insertOne({
    type: "order_confirmation",
    to: customer.email,
    orderId: order._id,
    total: order.total,
    status: "pending",
    createdAt: new Date(),
  });

  console.log(`Queued confirmation for order ${order._id}`);
};

The function receives the change event, which has the same shape as a change stream event: operationType, documentKey, fullDocument (when enabled), updateDescription (for updates), ns, and clusterTime. The trigger passes that event to your function every time a matching change happens.

Notice that this function writes to an outbox collection rather than calling an email API directly. That's a deliberate pattern covered later in this guide.

The context Object

Functions don't get environment variables or connection strings in the usual way. Instead, they get a global context object:

MemberWhat it gives you
context.servicesAccess to linked data sources, e.g. context.services.get("mongodb-atlas")
context.valuesNamed configuration values, including ones linked to secrets
context.functionsCall other Functions: context.functions.execute("name", arg)
context.environmentThe current environment tag and environment-specific values

The name you pass to context.services.get() is the name of the linked data source, which is mongodb-atlas by default. The object you get back behaves like a slimmed-down driver: db(), collection(), find(), findOne(), insertOne(), updateOne(), aggregate(), bulkWrite(), and so on.

Storing secrets

Never hardcode API keys in function source. Create a Secret, then create a Value linked to that secret, and read it at runtime:

exports = async function (changeEvent) {
  const apiKey = context.values.get("emailApiKey");
  // use apiKey to authenticate with your provider
};

Calling external APIs

Functions can import npm packages that you add as dependencies. The older context.http client has been deprecated, so prefer a standard HTTP library you add yourself:

exports = async function (changeEvent) {
  const axios = require("axios");
  const webhookUrl = context.values.get("slackWebhookUrl");

  const order = changeEvent.fullDocument;
  await axios.post(webhookUrl, {
    text: `New order ${order._id} for ${order.total.toString()}`,
  });
};

Keep dependencies small. Every package you add increases cold-start time and the surface area you're responsible for.

Filtering Events with Match and Project

By default, a database trigger fires for every event of the selected operation types. For busy collections, that's wasteful. A match expression filters events on the server side before your function is ever invoked.

Say you only care when an order's status changes to shipped:

{
  "operationType": "update",
  "updateDescription.updatedFields.status": "shipped"
}

The match expression is evaluated against the change event, so you use change event paths like updateDescription.updatedFields.status or fullDocument.region, not raw document paths.

A project expression trims the event before it's sent to the function. If the documents are large and you only need a couple of fields, projecting reduces the payload:

{
  "operationType": 1,
  "documentKey": 1,
  "fullDocument.customerId": 1,
  "fullDocument.status": 1
}

Filtering in the match expression is always better than filtering with an if at the top of your function. You don't pay for executions you don't need, and you don't clutter your logs.

Full Document and Pre-Images

For update events, the change event only includes what changed (updateDescription) unless you enable Full Document, which looks up the current version of the document after the change. That lookup happens at the time the event is processed, so under heavy write load the "full document" might reflect a later state than the one that produced the event.

If you need the state before the change, enable Full Document Before Change. This requires change stream pre- and post-images to be enabled on the collection:

db.runCommand({
  collMod: "orders",
  changeStreamPreAndPostImages: { enabled: true },
});

Pre-images take extra storage and write overhead, so enable them only on collections where you genuinely need to compare before and after, such as audit logs. For a deeper look at the change event format and building audit trails, see building an event-driven audit log with MongoDB change streams.

Pattern: Keeping Denormalized Data in Sync

MongoDB schemas often duplicate data for read performance, such as storing a product's name and price on each order line or an author's display name on every post. Triggers are a clean way to keep those copies up to date.

Here's a trigger on the users collection that updates the author name embedded in posts whenever a user changes their display name. Use this match expression so it only fires on name changes:

{
  "operationType": "update",
  "updateDescription.updatedFields.displayName": { "$exists": true }
}

And the function:

exports = async function (changeEvent) {
  const userId = changeEvent.documentKey._id;
  const newName = changeEvent.updateDescription.updatedFields.displayName;

  const posts = context.services
    .get("mongodb-atlas")
    .db("blog")
    .collection("posts");

  const result = await posts.updateMany(
    { "author._id": userId },
    { $set: { "author.displayName": newName } },
  );

  console.log(
    `Updated ${result.modifiedCount} posts for user ${userId.toString()}`,
  );
};

This is eventually consistent: for a brief moment after the user update, posts will show the old name. For display data like this, that's usually fine. If you need both writes to succeed or fail together, use a transaction in your application code instead.

Scheduled Triggers

Scheduled triggers run a function on a CRON schedule. Atlas uses standard five-field CRON syntax, evaluated in UTC:

┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sunday = 0)
│ │ │ │ │
5 0 * * *     -> every day at 00:05 UTC
*/15 * * * *  -> every 15 minutes
0 9 * * 1     -> Mondays at 09:00 UTC

A common use is a nightly rollup. This function aggregates yesterday's orders into a daily_sales collection using $merge, so re-running it for the same day is idempotent:

exports = async function () {
  const db = context.services.get("mongodb-atlas").db("shop");

  const end = new Date();
  end.setUTCHours(0, 0, 0, 0);
  const start = new Date(end);
  start.setUTCDate(start.getUTCDate() - 1);

  await db
    .collection("orders")
    .aggregate([
      { $match: { createdAt: { $gte: start, $lt: end }, status: "paid" } },
      {
        $group: {
          _id: {
            day: { $dateTrunc: { date: "$createdAt", unit: "day" } },
            region: "$region",
          },
          revenue: { $sum: "$total" },
          orders: { $sum: 1 },
        },
      },
      {
        $merge: {
          into: "daily_sales",
          on: "_id",
          whenMatched: "replace",
          whenNotMatched: "insert",
        },
      },
    ])
    .toArray();

  console.log(`Rolled up sales for ${start.toISOString().slice(0, 10)}`);
};

Note the .toArray() at the end. An aggregation with $merge or $out doesn't execute until you iterate the cursor, so without it the function would finish without doing anything.

Ordering, Throughput, and Retries

By default, database triggers process events in order: the next event isn't handled until the previous function execution finishes. That's safe but limits throughput, because a slow function holds up the whole queue.

If your function doesn't depend on ordering (sending a notification, for example), disable Event Ordering so Atlas can run executions concurrently. For very high-volume collections, look at the Maximum Throughput option, which increases concurrency further for supported configurations.

Triggers are backed by a change stream with a resume token. If a trigger is suspended (for example, because the function threw repeatedly or the resume token fell off the oplog), you can restart it, and Atlas will attempt to resume from where it stopped. If the oplog has rolled past the stored token, you'll have to restart without resuming, which means some events are skipped. Monitor trigger status and keep a comfortable oplog window on busy clusters.

Because events can be retried and processed more than once in edge cases, write functions to be idempotent. Upserts keyed on a natural ID, $merge with on: "_id", and checking a processedAt field are all good techniques.

Pattern: The Outbox

Calling external services directly from a database trigger works, but it couples your trigger to the availability of that service. If the email provider is down, your function throws, and depending on configuration the trigger can be suspended.

A sturdier pattern is the transactional outbox:

  1. The database trigger (or your application) writes a small job document to an outbox collection.
  2. A second trigger on outbox inserts, or a scheduled trigger every minute, picks up pending jobs, calls the external API, and marks them sent or increments an attempts counter.
exports = async function () {
  const outbox = context.services
    .get("mongodb-atlas")
    .db("shop")
    .collection("outbox");

  const jobs = await outbox
    .find({ status: "pending", attempts: { $lt: 5 } })
    .limit(50)
    .toArray();

  for (const job of jobs) {
    try {
      await context.functions.execute("sendEmail", job);
      await outbox.updateOne(
        { _id: job._id },
        { $set: { status: "sent", sentAt: new Date() } },
      );
    } catch (err) {
      await outbox.updateOne(
        { _id: job._id },
        { $inc: { attempts: 1 }, $set: { lastError: String(err) } },
      );
    }
  }
};

Now a provider outage delays emails instead of breaking your data pipeline, and you have a record of every attempt.

Forwarding Events to AWS EventBridge

If your backend already lives on AWS, a database trigger can send events directly to Amazon EventBridge instead of calling a function. You configure the AWS account ID and region, then associate the partner event source in AWS. From there, you can route events to Lambda, SQS, Step Functions, or anything else EventBridge supports.

This is a good fit when you want MongoDB changes to fan out to many consumers, or when your team's event processing standards are built around AWS tooling.

Managing Triggers as Code

Clicking through the UI is fine for experiments, but production triggers should be versioned and deployed like the rest of your code. You can manage triggers with the Atlas CLI and the Atlas Admin API, which lets you keep function source and trigger configuration in your repository and deploy them from CI. Check the current Atlas CLI and Admin API documentation for the exact commands and payloads, since this tooling has evolved alongside the App Services changes.

Whatever tool you use, keep these in source control:

  • The function source code.
  • The trigger configuration (collection, operation types, match and project expressions).
  • Dependency manifests.
  • A list of required Values and Secrets (names only, never secret contents).

Limits and When Not to Use Triggers

Functions are designed for short, event-driven work. They have execution time and memory limits (check the current docs for exact values), and they aren't meant for long-running jobs like large data migrations or video processing.

Triggers are a poor fit when:

  • You need strict synchronous behavior. The trigger runs after the write commits. If the logic must succeed for the write to count, do it in the application inside a transaction.
  • The work is long-running or CPU-heavy. Hand it off to a queue and a worker.
  • You need complex local testing and debugging. Functions run in Atlas's runtime. Keep business logic in plain modules you can unit test, and keep function bodies thin.
  • You aren't on Atlas. Self-hosted deployments can get the same effect by running a small service that consumes change streams directly.

Common Pitfalls

Infinite trigger loops. A trigger on orders that updates orders will fire itself again. Either write to a different collection, or use a match expression that excludes the fields your function changes.

Filtering in code instead of the match expression. Every execution counts toward usage and log noise. Filter on the server with a match expression wherever you can.

Forgetting .toArray() on aggregations with $merge or $out. The pipeline won't run until the cursor is iterated.

Assuming exactly-once delivery. Design for at-least-once. Make every function safe to run twice for the same event.

Leaving event ordering on for independent work. Ordered processing serializes executions. If order doesn't matter, turn it off and let Atlas parallelize.

Ignoring suspended triggers. A suspended trigger silently stops processing. Set up an Atlas alert or check trigger health as part of your monitoring.

Conclusion

Atlas Triggers and Functions give you a small, managed runtime for the logic that should happen when data changes or when a clock ticks. Database triggers handle denormalization, notifications, and audit trails; scheduled triggers handle rollups and cleanup. With match expressions to filter events, idempotent functions, and an outbox for external calls, they're reliable enough for real production workloads without any extra infrastructure.

Pick one background job in your application that currently runs as a cron script or an in-request side effect, such as a nightly summary or a "send welcome email" call, and move it into a scheduled or database trigger this week. Once you see how little code it takes, you'll find plenty of others.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading