Best Practices
Follow tips for writing reliable, efficient ScriptRunner scripts, from testing safely and using async correctly to handling errors and time limits.
Whether you are new to TypeScript or migrating from Data Center, these tips will help you avoid common pitfalls and get the most out of the platform.
Always test in a non-production environment
Scripts that perform updates, such as creating or editing work items, transitioning issues, sending notifications, apply changes to your Jira instance immediately when run. Always test in a non-production environment before deploying to production.
Know when to fetch the full work item
In several features, your script receives a work item object that appears complete but contains only a few properties, such as id and key, which can be misleading. For example, writing event.issue.fields.priority in an event listener returns undefined even if the work item has a priority set. To read field data, fetch the work item with HAPI , requesting the fields you need.
Here is how it works in each affected feature:
- Event listeners and workflow actions:
event.issueis a partial work item. UsegetByKey()to fetch it, requesting the fields you need:const issue = await Issues.getByKey(event.issue.key, { fields: ['priority'] }) - Workflow actions: The same applies. The default boilerplate script includes a commented-out example showing how to do this. Uncomment the
Issuesimport and thegetByKey()call to get started. - Workflow validators: The default boilerplate script already fetches the work item for you using
getByKey(). This is intentional: removing that line means field access silently returnsundefinedrather than throwing an error, which is difficult to debug. - Scripted fields: There is no
event.issue. UsegetById()with thecontext.idproperty instead:const issue = await Issues.getById(context.id, { fields: ['summary'] })
Use the script console to explore and experiment
The script console is the fastest way to test ideas, explore HAPI, and debug scripts before wiring them up to a feature. Use it to:
- Check what a HAPI method returns before using it in a listener or workflow rule
- Run one-off bulk updates safely
- Verify that your async/await logic works as expected
Use async and await correctly
Forgetting await is one of the most common mistakes when writing TypeScript scripts. Without it, you get a Promise object instead of the actual data, and your script will behave unexpectedly without throwing an obvious error.
// ✅ Correct
const issue = await Issues.getByKey('JRA-1')
console.log(issue.key)
// ❌ Incorrect — issue is a Promise, not an Issue object
const issue = Issues.getByKey('JRA-1')
console.log(issue.key) // undefinedIf a value is coming back as undefined or [object Promise], a missing await is usually the cause.
The await keyword can only be used inside an async function. Your script's entry point is always async by default, but if you extract logic into helper functions, remember to mark those as async too.
// ✅ Correct
const getIssue = async (key: string) => {
return await Issues.getByKey(key)
}
// ❌ Incorrect — await cannot be used in a non-async function
const getIssue = (key: string) => {
return await Issues.getByKey(key) // SyntaxError
}Run independent operations in parallel
When you need to fetch or update multiple things that do not depend on each other, use Promise.all() to run them simultaneously. This can significantly reduce execution time.
// ❌ Sequential — takes 3x as long
const issue1 = await Issues.getByKey('JRA-1')
const issue2 = await Issues.getByKey('JRA-2')
const issue3 = await Issues.getByKey('JRA-3')
// ✅ Parallel — all three fetched at once
const [issue1, issue2, issue3] = await Promise.all([
Issues.getByKey('JRA-1'),
Issues.getByKey('JRA-2'),
Issues.getByKey('JRA-3'),
])Use sequential await only when one operation depends on the result of another.
Only fetch the fields you need
When fetching work items, specify only the fields your script actually uses. Fetching unnecessary fields increases the response size and slows your script.
// ✅ Only fetch what you need
const issue = await Issues.getByKey('JRA-1', {
fields: ['summary', 'status'],
})
// ❌ Fetches everything — slower and wasteful
const issue = await Issues.getByKey('JRA-1')This is especially important when iterating over large search result sets.
Use optional chaining to avoid null errors
Jira fields are not always populated. Accessing a property on a null or undefined value will throw an error and stop your script. Use optional chaining ( ?.) and nullish coalescing ( ??) to handle missing values safely.
// ✅ Safe — returns undefined if fields or priority is null
const priority = issue?.fields?.priority?.name
// ✅ Safe — falls back to 0 if story points is null
const storyPoints = issue?.fields?.StoryPoints ?? 0
// ❌ Unsafe — throws if fields is undefined
const priority = issue.fields.priority.nameLet errors fail the run
ScriptRunner already catches anything your script throws. The error and its stack are written to the invocation log, and the run is marked Failed, so it shows up in the activity history and in the failure count for event listeners and scheduled jobs.
Because of that, the most common error-handling mistake is catching an error and only logging it:
// Don't do this: the run reports success
try {
await Issues.update({ issueIdOrKey: 'JRA-1', body: { fields: { summary: 'New' } } })
} catch (error) {
console.error(`Update failed: ${error}`)
}console.error is logged at ERROR severity, which is below the level that marks an invocation as failed. The script above swallows a real failure and reports Ok. Prefer letting the error propagate:
await Issues.update({ issueIdOrKey: 'JRA-1', body: { fields: { summary: 'New' } } })"Not found" is null, not an error
Reads return null when the target doesn't exist or you can't see it. Test for that explicitly instead of wrapping the call in try/catch:
const issue = await Issues.getByKey('JRA-1')
if (!issue) {
// Decide whether this is expected (skip) or a real problem (throw)
throw new Error('JRA-1 not found, or not visible to this script')
}
await Issues.addComment(issue, { body: 'Done!' })A few helpers are typed as always returning a value but can still fail if the target doesn't exist, including Issues.addComment, Issues.getComment, and Resolutions.getById. Guard the input (for example, check the work item exists first) rather than relying on the result. Issues.transition does nothing at all if the work item doesn't exist, so check the work item first if the transition must happen.
Catch only when you will act on it
A try/catch earns its place when you can do something with the failure: recovering, adding context and rethrowing, or continuing a batch.
Add context, then rethrow so the run still fails:
try {
await Issues.transition(issue, 'Close Issue')
} catch (error) {
throw new Error(`Could not close ${issue.key}`, { cause: error })
}Branch on the HTTP status when different failures need different handling. The error carries the response status, and its message already contains Jira's own error text:
try {
await Issues.update({ issueIdOrKey: 'JRA-1', body: { fields: { customfield_10011: 'x' } } })
} catch (error) {
const status = (error as { response?: { status?: number } }).response?.status
if (status === 403) {
console.warn('No edit permission, skipping')
return 'Skipped: no permission'
}
throw error
}Batch loops: collect failures, then fail
In scheduled jobs, one bad work item shouldn't stop the other 500. Catch per item, keep going, and throw at the end so the run is still recorded as failed:
export default async function () {
const failures: string[] = []
let updated = 0
for await (const issue of Issues.search('project = JRA AND status = Open')) {
try {
await Issues.addComment(issue, { body: 'Reminder: still open' })
updated++
} catch (error) {
failures.push(`${issue.key}: ${error instanceof Error ? error.message : String(error)}`)
}
}
if (failures.length) {
throw new Error(`Commented on ${updated}, failed on ${failures.length}:\n${failures.join('\n')}`)
}
return `Commented on ${updated} work item(s)`
}Two APIs that fail without throwing
Issues.createMany reports per-item problems in its result rather than throwing:
const result = await Issues.createMany({ body: [...] })
if (result.errors?.length) {
throw new Error(`${result.errors.length} of the work items could not be created`)
}
A raw fetch call returns the response whatever the status, so check it:
const response = await fetch('/rest/api/3/myself')
if (!response.ok) {
throw new Error(`Jira returned ${response.status}: ${await response.text()}`)
}Always await
Unawaited promises are detected after your script finishes and fail the run. Rate limits (HTTP 429) are not retried automatically, so if you make many calls in a loop, await each one rather than firing them all at once.
What you cannot catch
Execution timeouts are enforced and terminate the script. You cannot catch one, so break long-running work into smaller batches instead.
Use console.log to debug
Use console.log to inspect values at runtime. The output appears in the script console's output panel, and in the logs for listeners and scheduled jobs. This is the quickest way to understand what your script is actually receiving and returning.
const issue = await Issues.getByKey('JRA-1')
console.log(JSON.stringify(issue)) // Inspect the full shape of the objectRemove or comment out debug logs before deploying to production to keep your output clean.
Be mindful of execution time limits
Scripts run with a 20-second execution limit. Long-running scripts, particularly those that iterate over large result sets or make many sequential API calls, can hit this limit and fail. See Scripting limitations for the full list of limits.
To stay within limits:
- Use parallel execution with
Promise.all()where possible - Only fetch the fields you need
- Spread large bulk operations across multiple scheduled runs
- Avoid unnecessary API calls inside loops
Always declare return types in scripted fields
ScriptRunner is permissive by default; if your scripted field returns an incorrect value, the field will silently render as empty rather than throwing an error. This can make bugs hard to track down.
To catch return type errors early, explicitly declare the return type on your function signature. TypeScript will then flag any mismatches when you write the script, before it ever runs. For example, a scripted field that should return a user might look like this:
export default async function (context: DateTimeScriptFieldContext): Promise<User | null>With this declaration in place, returning an incorrect value, such as { name: "Gustavo Bishop" } as a plain object instead of a proper User type, will produce a type error immediately, rather than silently rendering as empty at runtime.
This applies to all scripted field types. Always match the return type in your function signature to the field type you have configured.
Choose the right feature for the job
Using the wrong feature for a task makes scripts harder to maintain and debug. As a quick guide:
- Need to react to a work item change? Use an event listener for broad reactions across spaces, or a workflow rule if the logic is tied to a specific transition.
- Need to display calculated data in a field? Use a scripted field.
- Need to control how fields behave on a screen? Use field behaviours.
- Need to run something on a schedule? Use a scheduled job.
- Need to expose Jira data to an external system? Use an HTTP endpoint.
- Need to add custom logic to an automation flow? Use an automation integration.
When in doubt, the script console is always a good place to start. You can prototype logic there before committing it to a feature.