Clock-In / Clock-Out: Rearchitecting Shift Tracking For The Real World

Dhruv Oberoi
Sep 9, 2025 • 5 minutes

Justworks helps small businesses thrive by running payroll, providing benefits, and helping ensure payroll tax compliance. For some of our customers, tracking an hourly employee’s shift is a crucial part of their business. This is a class of complex, real-time, and compliance-heavy challenges that have become more pronounced as we’ve scaled our systems.

As Justworks expands, our ability to support an ever-increasing list of product demands at pace is paramount. Recently our Time team began untangling the architecture that blocked key features.

One such architecture challenge to overcome was centralizing the source of truth for ‘time worked’. The logic and data for hours, breaks, and real-time events were living across multiple services:

  • Legacy monolith: Owns shift records and streams open shift data to web clients.

  • Mobile app: Houses the logic that decides when to notify hourly workers.

  • Downstream syncs: Syncs pay-related data to different consuming services.

This level of complexity created some maintenance burdens for our team in the form of an ever-growing backlog of small fixes that we had to coordinate across multiple services.

This article follows our re-architecture efforts and how moving complexity into Temporal(opens in a new tab) workflows gave us a durable, unified way to track shifts.

Legacy Architecture

Figure 1. Legacy flow: two sources of truth, client-side notifications.

Why this blocks us:

  1. Client-Side timers: Mobile apps downloaded break rules and ran local timers, which died if the OS suspended the app.

  2. Scatter-shot ownership: Logic executes in mobile while the rules live in the monolith, so every feature tweak still touches two codebases.

  3. Interface sprawl: Years of shifting ownership have layered the monolith with mismatching implementions. For instance, the Mobile app uses GraphQL while the web client uses good ol’ REST endpoints.

Centralising Shift State with TimeWorked & Temporal Workflows

Enter TimeWorked, a new service written in Go, backed by Temporal, that now owns any and all “time worked” data on the Justworks platform, including, but not limited to, what we call “open shift” data.This service leverages the power of long-running Temporal workflows(opens in a new tab) to hold state for any open shift that has been started by our web or mobile-based clients.When a worker clocks-in, we create a new “OpenShiftWorkflow” which holds the shift’s state, manages break timers, and triggers push notifications via our Communications service.For the purpose of this blog, I will talk about the mobile shift push notification flow for which we partnered with the Mobile and Communications teams as it was the first use case we now support with this architecture.

New Architecture

Figure 2 — New architecture: one source of truth, durable timers, channel-agnostic notifications.

This architecture improves on the legacy flow in multiple ways:

  • Break-timer logic is server-side: Mobile and Web clients just need to call an endpoint to clock in.

  • Channel-agnostic notifications: SMS, email, or Slack can reuse the same workflow.

  • Durable timers & retries via Temporal, in addition to increased observability out-of-the-box for a given shift’s history.

Shift events are processed by the service that is closest to the data and can fan-out any follow-up actions in our distributed system.

Phased Rollout

As any engineer who values their sanity would tell you, switching over from a monolith to a shiny new revised architecture carries its risks in transferring production traffic from a battle tested codebase to something that has not yet developed those scars.

The way we have phased adoption of this new architecture is a step wise process to mitigate this risk:

  1. The legacy monolith still “owns” shifts and forwards any updates to the Time Worked service that handles the lower risk mobile notifications.

  2. TimeAPI will begin to dual-write shift data to both the monolith and TimeWorked. Production data is still served from the legacy system; mobile notification lifecycle is switched to using Time Worked exclusively.

  3. After gaining confidence on data parity, feature parity and performance at scale, we switch over the source of truth from the legacy system to Time Worked.

  4. Deprecate the legacy system, and all requests are routed by TimeAPI to the new architecture.

Deep dive: the OpenShiftWorkflow

This feature needs to ensure that users must get notifications for break-start, break-end, and clock-out of their shift based on what admins have selected for their company. We also need to manage not sending any notifications if a user starts / ends their break on time.

The general flow is that the provider responsible for owning the shift data (currently, the legacy system; eventually, TimeWorked) makes calls to:

  1. Start the shift

  2. Start a break

  3. End a break

  4. End the shift

Each of these actions can be considered a shift event.

Using Temporal to Manage Shift Lifecycle

We chose to use a Temporal workflow for managing the open shift lifecycle due to its support for very light-weight long-running workflows(opens in a new tab).

Unlike traditional state machines, where we would have had to build and maintain complex logic to track and manage thousands of concurrent shift states, Temporal abstracts that complexity away. It provides a durable, fault-tolerant runtime that allows us to model each open shift as its own isolated workflow. This not only simplifies our system architecture but also lets us scale to tens of thousands of open shifts with minimal infrastructure overhead and operational burden.

It manages idle-waiting (or rather, solves for busy-waiting(opens in a new tab)) by providing a Timers API(opens in a new tab) that allows us to only resume execution when a timer fires. Therefore, resource consumption of a workflow is measured by state-transitions and not time.

Additionally, workflows provide a simple interface to process events by using Signals(opens in a new tab) that allow us to externally pass a message to a running workflow.

Those two concepts, combined with the Go SDK’s channel-like APIs, drive 90% of the workflow logic.

Workflow Initiation Payload

Our endpoint accepts a payload that sends a list of rules with metadata that highlights when each break is supposed to start and end; as well as when a shift is supposed to end. All durations are passed in minutes.

Along with the member ID (in the payload) and a shift ID (part of the POST request path).

config := ShiftConfig{
  UserID: "user_123",
  BreakRules: []BreakRule{
    {
      Type:           "meal",
      DurationMins:   30,
      PromptStartMins: 240,  // 4 hours
      PromptEndMins:   270,  // 4.5 hours
    },
    {
      Type:           "rest",
      DurationMins:   15,
      PromptStartMins: 120,  // 2 hours
      PromptEndMins:   135,  // 2.25 hours
    },
  },
  Shift: ShiftDetails{
    AutoCheckoutAfterMins: 600,  // 10 hours
  },
}

Shift State in the Workflow

When the workflow is initialised, we set the shift as active and store all the breaks passed in the payload in a map for easy access later.

type shiftState struct {
  Active bool
  Breaks map[string]*breakState
}

type breakState struct {
  IsActive             bool
  StartBreakCancelFunc workflow.CancelFunc
  EndBreakCancelFunc   workflow.CancelFunc
}

Creating the Channels

We created one channel for each event type that will allow us to receive signals for each respective event type.

// Event identifiers
const (
  StartBreakEvent string = "start_break"
  EndBreakEvent   string = "end_break"
  EndShiftEvent   string = "end_shift"
) 

// Signals
startBreakCh := workflow.GetSignalChannel(ctx, timeworked.StartBreakEvent)
endBreakCh := workflow.GetSignalChannel(ctx, timeworked.EndBreakEvent)
endShiftCh := workflow.GetSignalChannel(ctx, timeworked.EndShiftEvent)

Managing Timers and their Futures

We initialise timers for each break rule and their associated cancel functions and store in shiftState. This code snippet highlights how we go about creating our initial state.

Figure 3 — Initialising state for a new workflow

for _, rule := range breakRules {
  // Initialize state for this break category.
  breakState[rule.Category] = &BreakState{}
  
  if rule.PromptAfter > 0 {
    // Create a cancellable context for the timer.
    timerCtx, cancel := withCancel(ctx)
    breakState[rule.Category].CancelFunc = cancel
    
    // We initialise the actual timer that will wait for the duration above
    // before executing the code in the future.
    duration := time.Duration(rule.PromptAfter) * time.Minute
    timer := startTimer(timerCtx, duration)
    
    category := rule.Category
    selector.Add(timer, func() {
      // If the shift and the break are active, we send the notification
      if shiftActive && !breakState[category].IsActive {
        notifyBreakStart(category)
      }
    })
  }
}

After setting up the timers and their futures, we have a selector loop that waits for channel messages:

for state.Active {
  selector.Select(ctx)
}

Managing Shift Events as Signals to the Workflow

As mentioned above, each shift event is received as a signal by the workflow.

Figure 4—Managing a start break signal

The following is an example on how we manage a signal received to start a break. For brevity, non-essential code is omitted.

// AddReceives registers a callback to a signal received – in this case, a
// startBreak event.
selector.AddReceive(startBreakSignal, func(ch ReceiveChannel, _ bool) {
  var event BreakEvent
  ch.Receive(ctx, &event)
  
  category := event.Type
  breakState := activeBreaks[category]

  // Mark the break as active and cancel any existing start prompt timer.
  breakState.IsActive = true
  if breakState.CancelFunc != nil {
    breakState.CancelFunc()
  }

  // We find the existing break rule based on the category
  var rule := findBreakRule(cat, wf.input.WorkBreakRules)

  // Initialise a new timer for ending a break if prompt to end is set for
  // this break rule.
  if rule != nil && rule.PromptToEndAfter > 0 {
    // This follows the same logic as setting a new future for a start break
    // timer elaborated on above.
    .
    .
    .
  }
 })

Putting It All Together

One of the biggest nice-to-haves that we have gained from this approach is getting a snapshot of active shifts happening in real-time. This allows for easier debugging and observability.

The following is the timeline of a shift with multiple breaks and notifications that took place in a production

Figure 5—Temporal timeline view for an open shift

Figure 6—Event log

At a glance we can tell that the user took two breaks, and was sent three notifications for starting / ending breaks in a very organic timeline view.

This sets a powerful product and technical precedent for observability and audibility in a product that has very strict compliance requirements.

Conclusion and What’s Next

This initiative has set in motion one of the more ambitious rewrites that we have attempted in Time. While refactors tend to be less risky, by moving critical logic from clients into a single, durable workflow, we traded deep-rooted complexity for a platform we can now extend with confidence.

It’s also a clear step toward our long-term goal: all “time-worked” data lives in one domain-isolated service, and every downstream consumer reacts to its events as the single source of truth.

Lessons We’re Carrying Forward

  • Shadow traffic + dual-write diff jobs keeps risk around release and data-loss near zero.

  • Temporal’s deterministic replays let us debug real failures locally.

  • Workflow event history doubles as a free, searchable audit log.

We will be extending on this workflow to eventually handle any-and-all Clock-In / Clock-Out use cases that either we currently support or will eventually need to support.

Do you want to build code that helps entrepreneurs and small businesses grow with confidence? We’re hiring across our Technology teams, come build with us! Check out our Careers page.