Skip to main content
The @async annotation marks functions as asynchronous, providing hints to the IDE and developers that a function performs asynchronous operations using coroutines or async/await patterns.

Syntax

---@async
Place this annotation before async function definitions.

Why Mark Functions as Async?

The @async annotation helps:
  • Document intent: Clearly shows which functions are asynchronous
  • IDE support: Enables async-aware analysis and warnings
  • Type checking: Validates async/await patterns
  • Developer guidance: Warns when async functions are called incorrectly
function fetchData(url)
    return coroutine.wrap(function()
        -- Async operation
        return coroutine.yield()
    end)()
end

-- No indication this is async
-- ❌ Unclear function behavior
-- ❌ No IDE warnings for improper usage

Basic Async Functions

1
Simple Async Operation
2
---@async
---@param url string Request URL
---@return string Response content
function fetchData(url)
    return coroutine.wrap(function()
        print("Starting request:", url)
        
        -- Simulate network delay
        local co = coroutine.running()
        timer.setTimeout(function()
            coroutine.resume(co, "Response data: " .. url)
        end, 1000)
        
        return coroutine.yield()
    end)()
end

-- Usage
local data = fetchData("https://api.example.com/users")
print("Received:", data)
3
Async File Operations
4
---@async
---@param filepath string File path
---@return string File content
function readFileAsync(filepath)
    return coroutine.wrap(function()
        local file = io.open(filepath, "r")
        if not file then
            error("Cannot open file: " .. filepath)
        end
        
        local content = file:read("*a")
        file:close()
        
        -- Simulate async reading
        coroutine.yield()
        return content
    end)()
end

---@async
---@param filepath string File path
---@param content string Content to write
---@return boolean success Whether write succeeded
function writeFileAsync(filepath, content)
    return coroutine.wrap(function()
        local file = io.open(filepath, "w")
        if not file then
            return false
        end
        
        file:write(content)
        file:close()
        
        -- Simulate async writing
        coroutine.yield()
        return true
    end)()
end
5
Async Database Operations
6
---@async
---@param query string SQL query
---@return table[] Query results
function queryDatabase(query)
    return coroutine.wrap(function()
        print("Executing query:", query)
        
        -- Simulate database connection and query
        local co = coroutine.running()
        database.execute(query, function(results)
            coroutine.resume(co, results)
        end)
        
        return coroutine.yield()
    end)()
end

Error Handling in Async Functions

---@async
---@param operation string Operation name
---@return boolean success
---@return string? error Error message if failed
function safeAsyncOperation(operation)
    return coroutine.wrap(function()
        local success, result = pcall(function()
            -- Simulate potentially failing operation
            if operation == "fail" then
                error("Operation failed")
            end
            
            coroutine.yield()
            return "Operation completed"
        end)
        
        if success then
            return true, nil
        else
            return false, result
        end
    end)()
end

-- Usage with error handling
local ok, err = safeAsyncOperation("normal")
if not ok then
    print("Error:", err)
else
    print("Success")
end

Async/Await Pattern

---@class Promise
local Promise = {}

---@async
---@generic T
---@param executor fun(resolve: fun(value: T), reject: fun(reason: string))
---@return Promise
function Promise.new(executor)
    local promise = setmetatable({}, {__index = Promise})
    
    local co = coroutine.create(function()
        local resolveValue, rejectReason
        
        local function resolve(value)
            resolveValue = value
        end
        
        local function reject(reason)
            rejectReason = reason
        end
        
        executor(resolve, reject)
        
        if rejectReason then
            error(rejectReason)
        end
        
        return resolveValue
    end)
    
    promise.coroutine = co
    return promise
end

---@async
---@param userId number User ID
---@return string User data
function fetchUserDataAsync(userId)
    return Promise.new(function(resolve, reject)
        -- Simulate async fetch
        setTimeout(function()
            if userId > 0 then
                resolve("User data for ID: " .. userId)
            else
                reject("Invalid user ID")
            end
        end, 1000)
    end)
end

Chaining Async Operations

---@async
---@param userId number
---@return table User profile
function getUserProfile(userId)
    -- Fetch user data
    local userData = fetchUserDataAsync(userId)
    
    -- Fetch user posts
    local posts = queryDatabase("SELECT * FROM posts WHERE user_id = " .. userId)
    
    -- Fetch user settings
    local settings = readFileAsync("/users/" .. userId .. "/settings.json")
    
    return {
        user = userData,
        posts = posts,
        settings = settings
    }
end

Parallel Async Operations

---@async
---@param urls string[] List of URLs to fetch
---@return table[] Results from all requests
function fetchAllData(urls)
    return coroutine.wrap(function()
        local results = {}
        local completed = 0
        local total = #urls
        
        for i, url in ipairs(urls) do
            -- Start async fetch
            coroutine.wrap(function()
                local data = fetchData(url)
                results[i] = data
                completed = completed + 1
            end)()
        end
        
        -- Wait for all to complete
        local co = coroutine.running()
        local function checkCompletion()
            if completed == total then
                coroutine.resume(co, results)
            else
                timer.setTimeout(checkCompletion, 100)
            end
        end
        checkCompletion()
        
        return coroutine.yield()
    end)()
end

-- Usage
local urls = {
    "https://api.example.com/users",
    "https://api.example.com/posts",
    "https://api.example.com/comments"
}

local allData = fetchAllData(urls)
for i, data in ipairs(allData) do
    print("Result", i, ":", data)
end

Before/After Comparison

Without @async

function loadData(id)
    return coroutine.wrap(function()
        local data = fetch(id)
        coroutine.yield()
        return data
    end)()
end

function processData(id)
    -- ❌ Unclear if this is async
    local data = loadData(id)
    return process(data)
end
Problems:
  • No indication of async behavior
  • Easy to misuse async functions
  • No IDE warnings
  • Unclear control flow

With @async

---@async
---@param id number
---@return table
function loadData(id)
    return coroutine.wrap(function()
        local data = fetch(id)
        coroutine.yield()
        return data
    end)()
end

---@async
function processData(id)
    -- ✅ Clear async chain
    local data = loadData(id)
    return process(data)
end
Benefits:
  • Clear async markers
  • IDE understands async flow
  • Proper warnings for misuse
  • Self-documenting code

Real-World Example

---@class ApiClient
local ApiClient = {}

---@async
---@param endpoint string API endpoint
---@param options? {method?: string, body?: string, headers?: table<string, string>}
---@return {status: number, data: any, headers: table<string, string>} | nil
---@return string? error Error message if request failed
function ApiClient.request(endpoint, options)
    return coroutine.wrap(function()
        options = options or {}
        local method = options.method or "GET"
        
        print("Starting", method, "request to:", endpoint)
        
        -- Simulate async HTTP request
        local co = coroutine.running()
        
        http.request({
            url = endpoint,
            method = method,
            body = options.body,
            headers = options.headers
        }, function(response, error)
            if error then
                coroutine.resume(co, nil, error)
            else
                coroutine.resume(co, response, nil)
            end
        end)
        
        return coroutine.yield()
    end)()
end

---@async
---@param userId number User ID
---@return table | nil User data
---@return string? error
function ApiClient.getUser(userId)
    return ApiClient.request("/api/users/" .. userId)
end

---@async
---@param userData {name: string, email: string, age: number}
---@return table | nil Created user
---@return string? error
function ApiClient.createUser(userData)
    return ApiClient.request("/api/users", {
        method = "POST",
        body = json.encode(userData),
        headers = {
            ["Content-Type"] = "application/json"
        }
    })
end

---@async
---@param userId number
---@param updates {name?: string, email?: string, age?: number}
---@return table | nil Updated user
---@return string? error
function ApiClient.updateUser(userId, updates)
    return ApiClient.request("/api/users/" .. userId, {
        method = "PUT",
        body = json.encode(updates),
        headers = {
            ["Content-Type"] = "application/json"
        }
    })
end

---@async
function main()
    -- Fetch existing user
    local user, err = ApiClient.getUser(123)
    if err then
        print("Error fetching user:", err)
        return
    end
    
    print("Found user:", user.data.name)
    
    -- Update user
    local updated, updateErr = ApiClient.updateUser(123, {
        age = user.data.age + 1
    })
    
    if updateErr then
        print("Error updating user:", updateErr)
        return
    end
    
    print("Updated user age to:", updated.data.age)
    
    -- Create new user
    local newUser, createErr = ApiClient.createUser({
        name = "Jane Doe",
        email = "[email protected]",
        age = 28
    })
    
    if createErr then
        print("Error creating user:", createErr)
        return
    end
    
    print("Created new user with ID:", newUser.data.id)
end

-- Start async main function
main()

Best Practices

Any function that uses coroutine.yield() or returns a coroutine should be marked @async.
If a function calls an async function, it should also be marked @async.
Use pcall or return error values to handle async failures gracefully.
Async functions should clearly document what they return, especially Promise or coroutine types.
Choose one async pattern (callbacks, coroutines, or promises) and use it consistently.

Common Async Patterns

Timeout Pattern

---@async
---@param operation fun(): any
---@param timeout number Timeout in seconds
---@return any | nil result
---@return string? error
function withTimeout(operation, timeout)
    local timedOut = false
    local result, error
    
    timer.setTimeout(function()
        timedOut = true
    end, timeout * 1000)
    
    result, error = operation()
    
    if timedOut then
        return nil, "Operation timed out"
    end
    
    return result, error
end

Retry Pattern

---@async
---@param operation fun(): any
---@param maxRetries number Maximum retry attempts
---@return any | nil result
---@return string? error
function withRetry(operation, maxRetries)
    local attempts = 0
    
    while attempts < maxRetries do
        local success, result = pcall(operation)
        if success then
            return result, nil
        end
        
        attempts = attempts + 1
        if attempts < maxRetries then
            -- Wait before retry
            coroutine.yield()
        end
    end
    
    return nil, "Max retries exceeded"
end

See Also

Build docs developers (and LLMs) love