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 settimeoutSeconds
. . Atlassian Developer ignoreSelf
filter works only for Jira events; you can also add anexpression
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 onundefined
. . 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_FIX
→bug-fix
). - Rebuild a dependency graph based on issue links.
- Normalize labels/tags (e.g.,
- 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
- One trigger, many events.
- One scheduled trigger for all periodic jobs.
timeoutSeconds: 900
only where needed (heavy scheduled work). . Atlassian Developer- Expression filter narrows noise before your code runs. . Atlassian DeveloperAtlassian Developer
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
- Group events, not functions
One trigger + internal dispatching saves module slots and reduces cold starts. . Atlassian DeveloperAtlassian Developer - Filter early
ignoreSelf
prevents loops when your app updates issues; expressions gate by project, issue type, etc. . Atlassian Developer - Use a single scheduled trigger
Fan out internally to multiple tasks. Keep the count ≤5 and avoid thefiveMinute
slot unless it is absolutely required. . Atlassian DeveloperThe Atlassian Developer Community - 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 - 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 - Mind the logs
Log lines are capped based on timeout. Implement structured logging and trimming. . Atlassian Developer - Develop with a temporary
webtrigger
, then remove it
Useforge webtrigger
for manual invocation during development, but delete the module from production manifests. . Atlassian DeveloperAtlassian Developer - 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.