Optimizing Triggers (on manifest.yml) in Atlassian Forge

Optimizing Triggers (and Your manifest.yml) in Atlassian Forge

How to cut module bloat, stay under platform limits, and route every event through a single, testable handler

Technical guide for Atlassian Forge developers on consolidating product events into one trigger, using filters (ignoreSelf, expressions), tightening scheduled jobs, and reducing the number of declared functions in manifest.yml. Includes a complete example (code you haven’t provided), file layout, and a text-based logic diagram—backed by current Forge documentation.


1. Why you must optimize (hard limits you can’t ignore)

  • Modules per app: 100 max — every extra trigger, function, webtrigger, etc., consumes this budget. . Atlassian Developer
  • Scheduled triggers: up to 5 per app; only one may use fiveMinute. . Atlassian Developer
  • Invocation rate limit: 1,200 calls per rolling minute. . Atlassian Developer
  • Runtime caps: 25 s for normal invocations; up to 900 s for functions invoked by scheduledTrigger or async events when you set timeoutSeconds. . Atlassian Developer
  • ignoreSelf filter works only for Jira events; you can also add an expression to skip unwanted payloads. . Atlassian Developer
  • Scheduled triggers are fired shortly (~5 minutes) after deployment, then follow the chosen interval. . The Atlassian Developer Community
  • Scheduled trigger requests are lightweight GETs without a product eventType, so you must branch on undefined. . Atlassian Developer

These constraints make it risky to sprinkle many small triggers/functions across the manifest. A consolidated pattern is safer and easier to evolve.


2. Design pattern: “One trigger to rule them all” + a dispatcher handler

Goal:
Declare a single trigger for all Jira/Lifecycle events you care about, plus (optionally) one scheduledTrigger. Route internally with a switch on event.eventType. This keeps module count low and centralizes cross-cutting concerns (logging, error handling, throttling).

Key docs that enable this pattern: trigger module with events array and filter block, and scheduledTrigger’s periodic execution model. Atlassian DeveloperAtlassian DeveloperAtlassian Developer


3. Example use case (no “reminder” or “approval” vocabulary)

Tag normalization & dependency synchronization

  • On issue/comment/attachment events:
    • Normalize labels/tags (e.g., BugFix, bug-fix, BUG_FIXbug-fix).
    • Rebuild a dependency graph based on issue links.
  • Hourly job:
    • Recompute sprint-level metrics (velocity snapshots, orphaned labels).
    • Prune stale cache entities.

4. Logic diagram (text-only)

Product/Lifecycle Event
           │
           ▼
  unifiedHandler (dispatcher)
           │  eventType?
           ├─ undefined  → runScheduledTasks()
           ├─ avi:jira:created:issue        → handleIssueCreated()
           ├─ avi:jira:updated:issue        → handleIssueUpdated()
           ├─ avi:jira:deleted:issue        → handleIssueDeleted()
           ├─ avi:jira:commented:issue      → handleCommentChanged()
           ├─ avi:jira:created:attachment   → handleAttachmentCreated()
           ├─ avi:forge:installed:app       → handleAppInstalled()
           └─ default → log "unhandled"

5. Optimized manifest.yml

yaml

modules:
  trigger:
    - key: unified-jira-trigger
      function: unifiedHandler
      events:
        - avi:forge:installed:app
        - avi:forge:upgraded:app
        - avi:jira:created:issue
        - avi:jira:updated:issue
        - avi:jira:deleted:issue
        - avi:jira:commented:issue
        - avi:jira:created:attachment
      filter:
        ignoreSelf: true
        expression: >
          ["OPS","ENG"].includes(event.issue?.fields?.project?.key) &&
          event.issue?.fields?.issuetype?.name !== "Sub-task"
        onError: RECEIVE_AND_LOG

  scheduledTrigger:
    - key: hourly-maintenance
      function: unifiedHandler
      interval: hour

function:
  - key: unifiedHandler
    handler: src/index.unifiedHandler
    timeoutSeconds: 900

6. File/Folder layout

src/
├── index.js                    # unifiedHandler dispatcher
├── handlers/
│   ├── handleIssueCreated.js
│   ├── handleIssueUpdated.js
│   ├── handleIssueDeleted.js
│   ├── handleCommentChanged.js
│   ├── handleAttachmentCreated.js
│   ├── handleAppInstalled.js
│   └── runScheduledTasks.js
├── services/                   # pure business logic
│   ├── tagNormalizer.js
│   ├── dependencyGraph.js
│   └── metrics.js
└── utils/
    ├── adf.js
    ├── jiraApi.js
    └── logger.js

This separation allows you to unit-test services without Forge context and keep handlers thin. . Atlassian Developer


7. Code (all new)

7.1

// src/index.js
import {
  handleIssueCreated,
  handleIssueUpdated,
  handleIssueDeleted,
  handleCommentChanged,
  handleAttachmentCreated,
  handleAppInstalled,
  runScheduledTasks
} from './handlers';

export const unifiedHandler = async (event, context) => {
  const type = event?.eventType; // undefined for scheduledTrigger

  try {
    switch (type) {
      case undefined:
        return await runScheduledTasks(context);

      case 'avi:forge:installed:app':
        return await handleAppInstalled(event);

      case 'avi:jira:created:issue':
        return await handleIssueCreated(event);

      case 'avi:jira:updated:issue':
        return await handleIssueUpdated(event);

      case 'avi:jira:deleted:issue':
        return await handleIssueDeleted(event);

      case 'avi:jira:commented:issue':
        return await handleCommentChanged(event);

      case 'avi:jira:created:attachment':
        return await handleAttachmentCreated(event);

      default:
        console.warn('Unhandled event type:', type);
    }
  } catch (err) {
    console.error('unifiedHandler error:', err);
    throw err;
  }
};

7.2 src/handlers/handleIssueUpdated.js

// src/handlers/handleIssueUpdated.js
import { normalizeTags } from '../services/tagNormalizer';
import { syncDependencies } from '../services/dependencyGraph';

export const handleIssueUpdated = async (payload) => {
  const issue = payload.issue;
  if (!issue) return;

  await normalizeTags(issue);
  await syncDependencies(issue);
};

7.3 src/handlers/runScheduledTasks.js

// src/handlers/runScheduledTasks.js
import { rebuildSprintMetrics } from '../services/metrics';
import { purgeObsoleteTags } from '../services/tagNormalizer';

export const runScheduledTasks = async () => {
  await Promise.all([
    rebuildSprintMetrics(),
    purgeObsoleteTags()
  ]);
};

8. Practical tips to “spend” fewer modules and invocations

  1. Group events, not functions
    One trigger + internal dispatching saves module slots and reduces cold starts. . Atlassian DeveloperAtlassian Developer
  2. Filter early
    ignoreSelf prevents loops when your app updates issues; expressions gate by project, issue type, etc. . Atlassian Developer
  3. Use a single scheduled trigger
    Fan out internally to multiple tasks. Keep the count ≤5 and avoid the fiveMinute slot unless it is absolutely required. . Atlassian DeveloperThe Atlassian Developer Community
  4. Adjust timeoutSeconds only where necessary
    Scheduled/async functions can run up to 900 s, but the rest stay at 25 s. Do not set long timeouts on every function. . Atlassian Developer
  5. Batch external calls
    You have a limit on outbound network requests per runtime minute. Aggregate requests or use the Async Events API for bulk jobs. . Atlassian Developer
  6. Mind the logs
    Log lines are capped based on timeout. Implement structured logging and trimming. . Atlassian Developer
  7. Develop with a temporary webtrigger, then remove it
    Use forge webtrigger for manual invocation during development, but delete the module from production manifests. . Atlassian DeveloperAtlassian Developer
  8. Know your event payloads
    Different Jira events expose different fields (changelog, comment, etc.). Always read the docs per event. . Atlassian Developer

9. Conclusion

Consolidating triggers and handle via a dispatcher function gives you:

  • Fewer declared modules (safer under the 100-module ceiling).
  • Fewer “surprise” invocations (thanks to filters).
  • Centralized error handling and metrics.
  • Room to add new features without refactoring the manifest every time.

Everything above aligns with Forge’s documented limits and module semantics. Adopt this pattern early, and you won’t need a painful refactor when you hit those quotas.

10 Likes

Nice post @PedroMorocho1

Great post!

The added benefit of using the dispatcher model is there is a greater chance the Lambda would be warm, reducing cold boots.

That’s a great point about warm Lambdas and performance. However, in many of our use cases, we deal with clients that have strict security and compliance policies. Often, they don’t allow data to leave the Atlassian environment or be processed externally not even temporarily.

That’s why we try to keep all logic inside Forge whenever possible, even if that means accepting occasional cold starts. Staying within Atlassian’s environment gives us better alignment with customer expectations and avoids complications related to data residency and auditing.