---
url: /tooling/debugging-workflows.md
description: Trace output, bisect failures, reproduce in Play, and read stack traces.
---

# Debugging Workflows

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You can read Output and Script Analysis first.
* You can trace with prints that you remove.
* You can bisect by disabling halves.
* You can reproduce a bug in a clean Play session.
* You can describe MicroProfiler and breakpoints honestly.
* You can read a stack trace top-down.

## Prerequisites

* [Debugging Basics](../programming-fundamentals/debugging-basics.md)
* [Scripts and Execution](../roblox-fundamentals/scripts-and-execution.md)
* [Testing](./testing.md)

## Read Output and Analysis first

Open Output before guessing. Read the error, the script name, and the line number. Open Script Analysis and fix every red mark in touched files. Analysis flags unknown globals, arity mismatches, and nilable abuses while typing.

Reproduce with a clean Play. Press Stop, then Play. Stale requires and old server state vanish. Trigger the bug once and copy the exact message.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
print(ShopConfig.shopName)
```

Save this probe as a temporary Script. Require one module. Print one field. Expected behavior: Output prints the shop name or it shows the exact missing-module error to fix first.

## Trace with prints you remove

Add small prints at boundaries. Print inputs on entry. Print decisions before returns. Print ids, counts, and branch names. Use `print` with values, with no behavior changes.

Keep traces short. Remove each trace when the bug closes. Committed prints spam every server and hide the next bug.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: unknown, count: unknown): ()
	print(player.Name)
	print(typeof(itemId))
	print(typeof(count))
	if typeof(itemId) ~= "string" then
		return
	end
	if typeof(count) ~= "number" then
		return
	end
	print(itemId)
end)
```

Create a RemoteEvent named `BuyItem` in ReplicatedStorage. Print the sender plus both types. Print the id only after it narrows to string. Expected behavior: Output shows which guard rejects bad traffic, so fix the sender or the check.

## Bisect by disabling halves

Halve the suspect area. Disable half the connections or half the folder. Rerun. Keep the half that still fails. Repeat until one script or one block remains.

Disable without deleting. Unplug a bootstrap line. Move a folder aside. Comment nothing in shipped code; remove the probe files after. Track each step on paper so no clean half gets retested.

**Script in ServerScriptService**

```luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")

local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local RoundService = require(ServerScriptService:WaitForChild("RoundService"))

local runShop: boolean = true
if runShop then
	ShopService.start({}, {})
else
	RoundService.start({}, {})
end
print("bisect")
```

Flip one boolean per run. Start exactly one Service. Expected behavior: Output prints `bisect` while only the enabled half can fail, which names the half owning the bug.

## Reproduce in a clean Play

Shrink the repro to three steps. List clicks, chats, or touches in order. Run those steps in a fresh Play with no extra windows. Confirm the bug hits every time.

Test both sides. Check the server log for authority rejects. Check the client log for missing remotes. The [client-server](https://create.roblox.com/docs/en-us/projects/client-server) split means a client-only repro often misses a server guard, and a server-only repro misses a replication lag covered in [Remotes and Networking](../roblox-fundamentals/remotes-and-networking.md).

**LocalScript in StarterPlayerScripts**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem", 5) :: RemoteEvent?
if basket == nil then
	return
end
basket:FireServer("Apple", 1)
print("fired")
```

Bound the wait. Fire one intent. Print one marker. Expected behavior: a clean Play prints `fired` once and the server log shows the matching accept or reject.

## Profile and pause honestly

The [MicroProfiler](https://create.roblox.com/docs/performance-optimization/microprofiler) graphs frame cost per system. Open it when frames hitch, not for logic bugs. Capture a hitch, find the tallest bar, and move that work out of the hot path. Menu paths change across Studio releases, so confirm the current open steps in Studio help (the guide opens it with `Ctrl+F6`) before teaching them.

Studio breakpoints pause a script on a line, per the [Studio debugging](https://create.roblox.com/docs/studio/debugging) guide. Set one breakpoint at the suspect guard. Inspect locals once. Resume and remove the breakpoint. Never ship a paused session as a fix. Confirm breakpoint behavior in the installed Studio build since pairing and UI wording shift by release.

Prefer prints and bisection for logic bugs. Reach for profilers for hitches and breakpoints for tangled state. Each tool answers one question well.

## Read stack traces top-down

Read the first line as the crash site. Read down for callers. Fix the top owned frame. Engine frames below it show the path, not the fault.

Match names and lines exactly. Open the named script. Check the guard above the crashing call. Most traces end at an unguarded nil index or a bad argument.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ShopMath = require(ReplicatedStorage:WaitForChild("ShopMath"))
local got = ShopMath.total(5, 2)
if got ~= 10 then
	assert(false, "shop total broke")
end
print(got)
```

Assert the contract at the call site. A failure prints the message with this line first. Expected behavior: Output prints `10` when green, or it points at this script first when the helper regresses.

## Common mistakes

**Mistake 1: you guess before you read.**

Wrong workflow below. It edits pricing when Output names a missing remote.

```text
Change ShopMath, replay, hope
```

It wastes runs and it risks new bugs. Fix it by reading Output and Analysis first, then by reproducing once before you edit.

**Mistake 2: you ship debug prints.**

Wrong example below. It logs every buy forever.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: string): ()
	print(player.Name)
	print(itemId)
	print(os.clock())
	print("buy hit")
end)
```

It floods live logs and it leaks timing habits. Fix it by removing traces when the bug closes and by keeping one meaningful event log per outcome.

**Mistake 3: you change two halves at once.**

Wrong workflow below. It toggles shop plus rounds together.

```text
Disable shop and rounds, replay, guess
```

A pass tells nothing about which half mattered. Fix it by flipping one boolean per run and by keeping the failing half.

## Exercises

1. Add three entry prints to a remote handler and find the rejecting guard.
2. Bisect two Services with one boolean flag.
3. Read a three-frame trace and name the frame you own.

::: details Solution 1
**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: unknown, count: unknown): ()
	print(player.Name)
	print(typeof(itemId))
	print(typeof(count))
	if typeof(itemId) ~= "string" then
		return
	end
	if typeof(count) ~= "number" then
		return
	end
	print("valid shape")
end)
```

Expected behavior: Output shows the exact guard that rejects each bad sender.
:::

::: details Solution 2
**Script in ServerScriptService**

```luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")

local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local RoundService = require(ServerScriptService:WaitForChild("RoundService"))
local runShop: boolean = false
if runShop then
	ShopService.start({}, {})
else
	RoundService.start({}, {})
end
print("bisect done")
```

Expected behavior: one half runs per Play, so a pass or fail names the owner.
:::

::: details Solution 3
**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ShopMath = require(ReplicatedStorage:WaitForChild("ShopMath"))
local got = ShopMath.total(5, 2)
if got ~= 10 then
	assert(false, "trace points here first")
	return
end
print(got)
```

Expected behavior: the top owned frame names this script, so you fix this call or the helper it calls.
:::

## From debugging to observability

Local debugging fixes one session; observability explains a live fleet. Ship a tiny logging wrapper with levels (debug/info/warn/error) that prefixes server/client context and funnels errors to one sink. Record gameplay funnels (join → tutorial → first purchase) with analytics events, aggregate crash counts per release, and gate risky rollouts behind a frozen config flag that flips without republishing code. When an incident lands, the top owned frame plus the release's error spike names which change to roll back first.

## Summary

Read Output and Analysis first. Trace briefly and remove traces. Bisect one half at a time. Reproduce cleanly in Play. Profile hitches and pause sparingly. Fix the top owned frame. Move to leveled logs, funnels, and flags for live triage. Next, format and guard the code in [Format, Lint, and Version Control](./format-lint-version-control.md).
