Different behaviour in activity and local activity

Hi, I’m working with the Temporal Go SDK and have a workflow where I start the execution using a key. Based on this key, I need to load a JSON definition from a database, parse it, and then run different activities depending on the values inside the JSON.

Some tasks in the JSON have a pause = true flag. When I encounter one of those tasks, the workflow uses workflow.Await and later continues execution through an Update API call. The update method returns workflow state back to the caller, which normally includes all computed values inside the workflow context.

Previously, I used to fetch the JSON from the database before starting the workflow. Recently, I moved that logic inside the workflow and created an activity to load this JSON. For performance reasons, I converted most of my activities into local activities, and they all worked fine — except the one that fetches data from the database.

When I made the DB-loading activity a local activity, I encountered unexpected behavior: when the Update API is invoked, the workflow context returned by the update handler contains less data than expected. It only includes the initial inputs, but some of the derived or computed fields are missing, even though they were updated earlier in the workflow.

I know Temporal recommends using regular activities for any external I/O, including database reads, but I wanted to understand why this happens with a local activity. Switching back to a normal activity fixes the issue, but the inconsistent state with the local activity was surprising and I wanted to get clarity from the community.

It is not expected. Are you 100% sure that you wait for the local activity result before the update returns?

yes, I did double-check that part.

In my setup, the workflow is started via gRPC, and right after that I call an Update API that returns the workflow state. Before any branching/processing happens, the workflow always executes one initial activity that loads the workflow definition from the DB.

The issue only appears when this DB-loading step is a local activity. When it’s local, the Update handler returns a reduced state (only the initial inputs). When I switch that same step back to a normal activity, the update returns the full state as expected.

I’m sharing a minimal version of the workflow below — all internal logic removed — just to show the structure and where the initial activity is executed. My goal here is to understand why using a local activity affects the workflow state seen by the Update call.

package workflow

import (
“encoding/json”
“fmt”

"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"

)

// Generic public-safe structs representing workflow metadata and definitions.
type FlowVariables struct{}
type WorkflowContext struct{}
type WorkflowDefinition struct {
Model  
TaskNode
VarMap map[string]VariableDef
}
type TaskNode struct {
ID       string
Name     string
TaskType string
Next     *NextNode
Input    map[string]any
}
type NextNode struct{ Node string }
type VariableDef struct{ Value any }

// Placeholder activity handler for demonstration.
type Activities struct{}

// Example dynamic workflow structure demonstrating:
// 1. Loading workflow definition
// 2. Walking through tasks dynamically
// 3. Executing activities/local activities based on task type
func DynamicWorkflow(ctx workflow.Context, reference string, fv *FlowVariables) (*FlowVariables, error) {

// Load workflow model (typically from a DB or external storage)
var workflowMap map[string]any
loadCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
    StartToCloseTimeout: 0,
    RetryPolicy: &temporal.RetryPolicy{
        InitialInterval:    0,
        BackoffCoefficient: 0,
        MaximumAttempts:    0,
    },
})

// All sensitive logic removed — only illustrating flow
err := workflow.ExecuteActivity(loadCtx, "LoadWorkflowDefinition", reference).Get(loadCtx, &workflowMap)
if err != nil {
    return nil, fmt.Errorf("failed to load workflow definition: %w", err)
}

// Convert the fetched map into a structured workflow definition
def, err := parseMapToWorkflowDefinition(workflowMap)
if err != nil {
    return nil, err
}

// Configure Local Activity options for task execution
ctx = workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{
    StartToCloseTimeout: 0,
    RetryPolicy: &temporal.RetryPolicy{
        MaximumAttempts: 0,
    },
})

activities := &Activities{}

// Build lookup for tasks by ID to support dynamic workflow jumps
taskMap := map[string]*TaskNode{}
for i := range def.Model {
    taskMap[def.Model[i].ID] = &def.Model[i]
}

// Find the START node of the workflow
var current *TaskNode
for _, node := range def.Model {
    if node.TaskType == "start" {
        current = &node
        break
    }
}

// Main execution loop — walks through tasks based on dynamic rules
for current != nil {

    switch current.TaskType {

    case "start":
        // Simply proceed to the next node
        if current.Next != nil {
            current = taskMap[current.Next.Node]
            continue
        }
        current = nil
        continue

    case "condition":
        // Branching logic hidden; demonstration only
        // Conceptually: evaluate branch expressions → pick matching next node
        current = pickNextBranchBasedOnConditions(current, taskMap)
        continue

    case "end":
        // Marks completion of the workflow
        current = nil
        continue

    default:
        // Execute a task via a Local Activity.
        // Actual internal logic is hidden; this demonstrates the pattern only.
        result := &WorkflowContext{}
        future := workflow.ExecuteLocalActivity(
            ctx,
            activities.RunGenericActivity, // generic placeholder
            current.ID,
            current.Name,
            current.TaskType,
            current.Input,
        )

        if err := future.Get(ctx, &result); err != nil {
            return nil, fmt.Errorf("activity failed: %w", err)
        }

        // Move to next task if defined
        if current.Next != nil {
            current = taskMap[current.Next.Node]
        } else {
            current = nil
        }
    }
}

return fv, nil

}

// Converts an untyped map into a typed workflow definition.
// Safe for public demonstration.
func parseMapToWorkflowDefinition(m map[string]any) (*WorkflowDefinition, error) {
b, err := json.Marshal(m)
if err != nil {
return nil, err
}
var def WorkflowDefinition
if err := json.Unmarshal(b, &def); err != nil {
return nil, err
}
return &def, nil
}

// Simplified branching helper for demonstration.
// Real branching conditions are intentionally not included.
func pickNextBranchBasedOnConditions(node *TaskNode, taskMap map[string]*TaskNode) *TaskNode {
if node.Next != nil {
return taskMap[node.Next.Node]
}
return nil
}

I don’t see update handler registration in this code.

I tried to abstract my actual code and mistakenly removed that part also.
But I found the solution, So let me tell you abrift, my temporal service based on go is separate micro service, so i have rest api there which internally does grpc call and start and resumes the workflow, and my other microservice can do grpc call directly to engine to start the flow,
now this behavior difference was not in case of rest calls, but in java grpc calls sometimes it was working and sometimes it does not work,

just after doing a local activity call ( which gets json from db ) I added dummy await workflow.Await(ctx, func() bool { return true })
And it works for java also, I assume as local activity runs on same process as workflow, somehow go sdk was persisting this in memory, and for java it wasn’t.