The Problem
If you’ve tried booking public golf in North Jersey recently, you know the frustration. Tee times disappear within minutes of opening, prices have skyrocketed year over year, and the competitive booking environment has turned what should be a relaxing weekend activity into a mad dash against the clock.
My friends and I often book last minute. The problem is, the good tee times are long gone when trying to book a day or two in advance. The only way to get a decent slot is to catch cancellations—constantly refreshing GolfNow, checking multiple courses, hoping someone drops out.
I got tired of losing out, so I built a monitoring system to do it for me. The system searches for available tee times at my preferred courses, tracks what’s been seen before, and notifies me the moment something new appears. It needed to be resilient enough to handle flaky API responses, efficient enough to avoid redundant searches when multiple users want the same criteria, and reliable enough to run continuously without babysitting.
This was also a perfect opportunity to dig deeper into building resilient, long-running workflows in an area that’s traditionally error-prone: web scraping. The complete implementation is open source on GitHub at lets-go-golfing.
Why Temporal
I’ve written extensively about Temporal.io in previous posts and deploying Temporal with Podman Quadlets, so I won’t rehash the full introduction here. But for those unfamiliar: Temporal is a workflow orchestration platform that lets you write long-running, fault-tolerant processes as simple, straightforward code.
For tee time monitoring, Temporal was the obvious choice:
- Automatic retries with backoff - Web scraping APIs can be flaky in general. Temporal handles transient failures without me writing retry logic.
- Durable execution - Workflows survive server restarts, deployments, and crashes. A scheduled search doesn’t disappear because I bounced the server.
- Long-running schedules - I need to search every 5-10 minutes, potentially for weeks. Temporal schedules make this trivial.
- Clear workflow-as-code - The business logic is readable Java code, not buried in configuration files or cron jobs.
The alternative would be cobbling together cron jobs, message queues, manual retry logic, and state management. Temporal gives me all of that out of the box.
System Architecture
Before diving into the code, here’s a high-level view of how everything fits together:

The flow is straightforward: Temporal schedules trigger workflows at regular intervals. Each workflow loads user preferences, searches the GolfNow API, filters results against previously seen tee times in the database, saves new discoveries, and sends email notifications for new matches. The multi-tenant database design ensures that search criteria and results are shared across users to minimize redundant API calls.
The Implementation
The Main Workflow
At the heart of the system is TeeTimeMonitorWorkflow, which orchestrates the entire search-filter-notify cycle. Here’s the implementation:
@WorkflowImpl(taskQueues = "golfnow")
public class TeeTimeMonitorWorkflowImpl implements TeeTimeMonitorWorkflow {
private final GolfNowActivities golfNowActivities = Workflow.newActivityStub(
GolfNowActivities.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(30))
.setHeartbeatTimeout(Duration.ofMinutes(5))
.setRetryOptions(
RetryOptions.newBuilder()
.setMaximumAttempts(3)
.setBackoffCoefficient(2.0)
.setInitialInterval(Duration.ofSeconds(10))
.build()
)
.build());
private final NotificationActivity notificationActivity = Workflow.newActivityStub(
NotificationActivity.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.setRetryOptions(
RetryOptions.newBuilder()
.setInitialInterval(Duration.ofSeconds(5))
.build()
)
.build());
@Override
public List<TeeTimeSlot> monitorTeeTimes(TTMonitorRequest request) {
// 1. Load user preferences from database
UserSearchPreferenceDto userPrefs =
golfNowActivities.loadUserSearchPreference(request.userSearchPreferenceId());
// 2. Search GolfNow API with user's criteria
List<TeeTimeSlot> allResults = golfNowActivities.searchTeeTimes(userPrefs);
if (allResults.isEmpty()) {
return allResults;
}
// 3. Filter out tee times we've already seen
List<TeeTimeSlot> newMatches =
golfNowActivities.filterPreviousMatches(allResults, userPrefs.searchCriteria());
// 4. Save all results to database (update last_seen_at if exists)
golfNowActivities.saveNewMatches(allResults);
// 5. Notify user only about new matches
if (!newMatches.isEmpty()) {
notificationActivity.sendTeeTimeNotification(
userPrefs.id(),
userPrefs.email(),
newMatches,
userPrefs.searchCriteria().numberOfPlayers()
);
}
return newMatches;
}
}
The workflow is remarkably clean thanks to Temporal’s “code to the happy path” philosophy. I’m not handling retries, timeouts, or failures explicitly—that’s all configured declaratively in the ActivityOptions.
Key design decisions:
-
Separate activities for different operations -
loadUserSearchPreference,searchTeeTimes,filterPreviousMatches,saveNewMatches, andsendTeeTimeNotificationare all distinct activities. This gives me fine-grained control over retry behavior and makes testing easier. If anything were to fail after the scraping, I could pick back up where it failed, and not have to re-scrape the API, potentially hitting rate limits. -
Exponential backoff for API calls - If GolfNow’s API is slow or returning errors, the workflow will retry with exponentially increasing delays (10s, 20s, 40s) before giving up.
-
Idempotent activities - Running
saveNewMatchesmultiple times produces the same result. The database upsert logic ensures that if an activity is retried, we don’t create duplicate records. -
Return only new matches - The workflow returns newly discovered tee times, making it easy to see what changed during this execution.
Scheduling System
Each user can have multiple search preferences, and each preference gets its own Temporal schedule. The schedule triggers the workflow at a user-defined interval—typically every 5-10 minutes.
Here’s how schedules are created:
public ResponseEntity<String> createTeeTimeSearchSchedule(UserSearchPreferenceDto userPrefs) {
String scheduleId = "tee-time-search-" + userPrefs.email();
Schedule schedule = Schedule.newBuilder()
.setAction(
ScheduleActionStartWorkflow.newBuilder()
.setWorkflowType(TeeTimeMonitorWorkflow.class)
.setArguments(new TTMonitorRequest(userPrefs.id()))
.setOptions(
WorkflowOptions.newBuilder()
.setWorkflowId("ttmonitor-" + userPrefs.email())
.setTaskQueue("golfnow")
.build())
.build())
.setSpec(
ScheduleSpec.newBuilder()
.setIntervals(List.of(
new ScheduleIntervalSpec(userPrefs.scheduleInterval())
))
.build()
)
.build();
ScheduleHandle handle = scheduleClient.createSchedule(
scheduleId,
schedule,
ScheduleOptions.newBuilder()
.setTriggerImmediately(true)
.build()
);
return ResponseEntity.of(Optional.of(scheduleId));
}
The schedule interval is stored as an ISO 8601 duration string (like PT5M for 5 minutes) in the user’s search preference. When a preference is created, the system automatically starts a Temporal schedule that runs indefinitely until the user deletes their search.
Important details:
- One schedule per user (currently) - The schedule ID is
tee-time-search-{email}. Eventually, I’ll support multiple schedules per user (one per search preference), but for now, this keeps things simple. - Trigger immediately - The schedule runs once right away, so users see results immediately rather than waiting for the first interval.
- Stored in Temporal - The schedule lives in Temporal’s system, not my database. If my application restarts, the schedules keep running.
Database Architecture
The database design was the trickiest part. I needed to support multiple users searching for the same tee times without making redundant API calls or storing duplicate data.
The schema has five main tables:
-
search_criteria- Stores reusable search parameters (location, radius, date, time preferences, price limits). Multiple users can share the same criteria. -
priority_courses- Tracks which specific golf courses a user cares about for a given search criteria (one-to-many withsearch_criteria). -
user_search_preferences- Links users to their search criteria, with settings likepayment_enabled,notify_enabled, andschedule_interval. -
tee_time_results- Globally shared records of individual tee times discovered (facility, time, price, booking URL). Not tied to specific users—this is the key to avoiding duplicate searches. -
user_notifications- Junction table tracking which users have been notified about which tee times. Prevents sending the same notification twice.
Why this design?
Let’s say two users both want to search for tee times at Preakness Valley on Saturday morning. Instead of making two identical GolfNow API calls, the system:
- Deduplicates the
search_criteria- Both users reference the same criteria record. - Shares
tee_time_results- When the first user’s workflow runs and finds 5 available slots, those get saved to the global results table. - Tracks notifications separately - The second user’s workflow sees those same 5 slots in
tee_time_results, checksuser_notifications, and only notifies about slots they haven’t been told about yet.
This architecture makes the system dramatically more efficient as it scales. With 100 users searching the same course, we make 1 API call, not 100. We also don’t store duplicate tee times in the db if it were per user.
The filtering logic in filterPreviousMatches is simple:
// Get all previously seen tee times for this search criteria
List<TeeTimeResultEntity> previousResults =
teeTimeResultRepository.findBySearchCriteriaAndTimeRange(
searchCriteria.id(),
startTime,
endTime
);
// Filter to only new tee times (by facility ID + time)
return allResults.stream()
.filter(slot -> !previousResults.contains(slot))
.collect(Collectors.toList());
Times are truncated to the minute to avoid false positives from sub-second differences.
Tech Stack and APIs
The system is built on a modern Java stack:
- Spring Boot - Application framework with dependency injection, configuration management, and REST APIs
- JPA/Hibernate - ORM for database operations with automatic schema generation
- PostgreSQL - Relational database for storing search criteria, results, and notification history
- Temporal Java SDK - Workflow orchestration and scheduling
- AWS SES - Email delivery for tee time notifications
REST APIs:
POST /api/user/searches- Create a new search preference (criteria + schedule)GET /api/user/searches?email={email}- List all active searches for a userGET /api/user/searches/{id}- Get a specific search preferenceDELETE /api/user/searches/{id}- Deactivate a search (soft delete)POST /api/tee-times/schedules- Manually create a Temporal schedule for a preferenceDELETE /api/tee-times/schedules/{email}- Delete a schedulePOST /api/tee-times/search- Manually search teetimesPOST /api/facilities/search- Manually search Facilities
The notification system uses AWS SES to send emails with booking links. When new tee times are found, users receive a formatted email like:
Subject: 3 New Tee Times Available!
We found 3 tee time(s) matching your search criteria:
1. Preakness Valley Golf Club at 2025-10-25 10:30 AM
Price: $70.00
Book now: https://www.golfnow.com/tee-times/facility/12345/...
2. Pinch Brook Golf Course at 2025-10-25 11:00 AM
Price: $68.00
Book now: https://www.golfnow.com/tee-times/facility/67890/...
Each booking URL is generated with the correct parameters for the number of players, date, and time, so users can complete their booking with a single click.
What’s Next
The core functionality is complete, but there’s plenty of room to expand:
Spring AI MCP Integration - I’m planning to wrap the search functionality as an MCP (Model Context Protocol) tool, giving LLMs a reliable way to search for tee times. Imagine asking Claude “Find me tee times at Preakness this Saturday morning under $80” and getting real-time results.
User Interface - Right now, everything is API-driven. A simple web UI for managing search preferences, viewing results, and scheduling workflows would make this accessible to non-technical users.
Browserbase Auto-Booking - The ultimate goal: when a preferred tee time becomes available and payment is enabled, automatically log in to GolfNow and complete the booking. This would use Browserbase for headless browser automation.
Authentication - Currently, there’s no user authentication. Adding Spring Security with OAuth would make this production-ready.
IP Rotation and Rate Limiting - To avoid getting rate-limited or blocked by GolfNow, proxy rotation would distribute requests across multiple IPs.
Scheduled Cleanup - A daily cron job to delete tee_time_results where the tee time has passed.
Try It Yourself
The complete implementation is open source and available on GitHub: lets-go-golfing. The repository includes all the code discussed in this post, along with setup instructions for running it locally or deploying to production.
You’ll need:
- Java 21+
- PostgreSQL database
- Temporal server (local via Temporal CLI or Temporal Cloud)
- AWS SES credentials (or you can modify the notification system to use a different email provider)
The README has detailed setup instructions, including database schema setup, environment configuration, and how to create your first tee time search.
Conclusion
Building a tee time monitoring system with Temporal turned what could have been a tangled mess of cron jobs, retry logic, and state management into clean, maintainable workflow code. The multi-tenant database design ensures efficiency at scale, and Temporal’s durability means I can trust the system to run indefinitely without intervention.
More importantly, it solved a real problem. I’m no longer frantically refreshing GolfNow hoping to catch a cancellation—I get a notification the moment something opens up. And when that email hits my inbox on a Friday night with a perfect Saturday morning slot, it feels like a small victory.
Now if you’ll excuse me, I have a tee time to book.