Clock-In / Clock-Out: Rearchitecting Shift Tracking For The Real World
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
Why this blocks us:
Client-Side timers: Mobile apps downloaded break rules and ran local timers, which died if the OS suspended the app.
Scatter-shot ownership: Logic executes in mobile while the rules live in the monolith, so every feature tweak still touches two codebases.
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
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:
The legacy monolith still “owns” shifts and forwards any updates to the Time Worked service that handles the lower risk mobile notifications.
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.
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.
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:
Start the shift
Start a break
End a break
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.
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.
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
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.











