Making AI Subtasks Appear One by One
Parsing OpenAI streaming responses in real time and delivering them to the client via SSE, so subtasks show up as they're created.
Making AI Subtasks Appear One by One
Fecit has a feature where AI auto-generates subtasks. It looks at the title and description of a task and suggests 3 to 5 sub-steps.
It worked fine, but there was one thing that bothered me. While the AI was thinking, a spinning star animation played, and when it was done, all the subtasks appeared at once. The wait was usually 3 to 5 seconds. During that time, the user saw nothing.
I wondered what it would feel like if subtasks appeared one at a time. Each one showing up the moment the AI finishes creating it. Less waiting, more feedback.
The Original Flow
The original flow was straightforward.
- The client calls the API.
- The server sends a request to OpenAI.
- OpenAI generates the entire JSON.
- The server parses the JSON and inserts all subtasks into the DB at once.
- The server responds to the client.
The OpenAI API was already being called with streaming enabled, but tokens were just concatenated into full_text, and when the stream ended, json.loads(full_text) parsed everything at once. None of the benefits of streaming were being used.
Parsing During the Stream
The idea is simple. Don’t wait for the JSON to be complete. Every time tokens come in, check if a subtask object has been completed.
The JSON structure from the AI looks like this:
{
"subTasks": [
{"title": "...", "description": "..."},
{"title": "...", "description": "..."}
]
}
Mid-stream, it looks something like:
{"subTasks": [{"title": "Pick a topic", "description": "Choose a blog topic and
Not valid JSON yet. Calling json.loads would throw an error. Appending closing brackets is fragile — it breaks when there are quotes or braces inside the description.
I went with tracking brace depth instead. After finding [, count { as depth+1 and } as depth-1. When depth returns to zero, one complete object has been found. Braces inside strings are ignored.
This function runs every time new tokens arrive. It remembers how many objects were previously extracted and only yields new ones.
Create One, Send It Immediately
Parsed subtasks are yielded from an async generator.
async for sub_task_data in generate_sub_tasks_streaming(...):
# Save to DB
await task_collection.insert_one(sub_task_doc)
# Update parent's sub_task_links
await task_collection.update_one(...)
# Publish SSE event
publish_event(achiever_id, str(parent_task.id), "update")
await asyncio.sleep(0)
That last line matters. publish_event uses asyncio.create_task() to publish the event, but if the event loop is busy, the task won’t run. sleep(0) yields control, letting the just-scheduled SSE task execute immediately. Without this one line, events pile up and get sent all at once. It took a while to figure that out.
The Client Was Already Ready
The desktop app was already doing real-time sync via SSE. When a task_record_updated event arrives, it re-fetches the task and updates the UI.
Each time the server creates a subtask, the parent task’s sub_task_links updates and an SSE event fires. The client fetches the parent, sees that subTaskLinks.length changed, and re-queries the subtask list.
I didn’t modify a single line of client code. The existing SSE infrastructure just worked.
How It Feels
When the AI generates subtasks, they appear in the list one by one, roughly 1 to 2 seconds apart. The AI’s token generation speed becomes a natural delay. No artificial setTimeout involved.
Watching a loading spinner for 3 seconds versus watching subtasks appear one at a time. The functionality is the same, but the experience is different. Just seeing something being built changes how the wait feels.
Lessons Learned
It wasn’t smooth sailing.
First, I tried appending closing brackets to incomplete JSON and calling json.loads. Prepared multiple suffixes like '"]}', '""]}' and tried each one. Predictably unstable. Quotes inside descriptions broke it immediately, and subtasks only appeared after the final parse when the stream ended. Streaming was pointless.
Then there was the issue of SSE events being published but never arriving at the client. The publish tasks scheduled via asyncio.create_task() weren’t running. The event loop was too busy consuming the OpenAI stream. Fixed with a single await asyncio.sleep(0), but it took time to find.