Optimizing procedurally generated terrain

Currently I am creating a infinite generation game like Minecraft. The code I have written works how I want it too, creating chunks, unloading chunks and storing them. However, If I want to make multiple layers of ground so you can mine underground like in Minecraft, It lags terrible and I have no idea how to optimize this.
How can I optimize my blocks in a procedurally generated world with multiple block layers and only load blocks that the player can see and update the terrain whenever a block is mined to load the new blocks?


local size = 3
local chunk_size = 15
local octaves = 6
local amplitude = 10
local frequency = 30
local L = 2
local gain = 1.4


function getHeight(x, z, X, Z)

	local y = 0
	local amp = amplitude
	local freq = frequency

	for i = 1,octaves do
		y = y + (math.noise((X*chunk_size+x)/freq, (Z*chunk_size+z)/freq) * amp)
		freq = freq*L
		amp = amp*gain
	end

	return y

end


game.ReplicatedStorage.World.CreateChunk.OnServerEvent:Connect(function(plr, chunkX, chunkZ)
	
	local chunkModel = Instance.new("Model", workspace.ActiveChunks)
	chunkModel.Name = "Chunk"
	local XValue, ZValue = Instance.new("NumberValue", chunkModel), Instance.new("NumberValue", chunkModel)
	XValue.Name = "x"
	ZValue.Name = "z"
	XValue.Value = chunkX
	ZValue.Value = chunkZ
	local loaded = false
	local loader = Instance.new("ObjectValue", chunkModel)
	loader.Value = plr
	loader.Name = "owner"
	
	for i, v in pairs(game.ReplicatedStorage.StoredChunks:GetChildren()) do
		if v:FindFirstChild("x") and v:FindFirstChild("z") then
			if v.x.Value == chunkX and v.z.Value == chunkZ then
				v.Parent = workspace.ActiveChunks
				v.owner.Value = plr
				loaded = true
			end
		end
	end

	
	if not loaded then
		for x = 1, chunk_size do
		
			for z = 1, chunk_size do
				
				
				local height = math.floor(getHeight(x, z, chunkX, chunkZ))*size
				local block = game.ReplicatedStorage.BlockDatabase.Grass:Clone()
				local items = 0
				
		local y = 0
				if height/3 > -1 then
		
					block.Parent = chunkModel
					block.Position = Vector3.new((chunkX*chunk_size)*size+chunk_size+(x*size), height+y*size, (chunkZ*chunk_size)*size+chunk_size+(z*size))
				end
				if height/3 < 0 then
					local water = game.ReplicatedStorage.BlockDatabase.Water:Clone()
					water.Parent = chunkModel
						water.Position = Vector3.new((chunkX*chunk_size)*size+chunk_size+(x*size), -.5+y*size, (chunkZ*chunk_size)*size+chunk_size+(z*size))
				end
				if height/3 < math.noise(x*.02, z*.02)*15 and height/3 > -1  then 	
					block:Destroy()
					local sand = game.ReplicatedStorage.BlockDatabase.Sand:Clone()
					sand.Parent = chunkModel
						sand.Position = Vector3.new((chunkX*chunk_size)*size+chunk_size+(x*size), height+y*size, (chunkZ*chunk_size)*size+chunk_size+(z*size))
				end
				if height/3 > math.clamp(math.noise(x*.02, z*.02), -.5, .5)*20 + 20 then
					block:Destroy()
					local stone = game.ReplicatedStorage.BlockDatabase.Stone:Clone()
					stone.Parent = chunkModel
						stone.Position = Vector3.new((chunkX*chunk_size)*size+chunk_size+(x*size), height+y*size, (chunkZ*chunk_size)*size+chunk_size+(z*size))
				end
				if height/3 > math.clamp(math.noise(x*.02, z*.02), -.5, .5)*30 + 35 then
					block:Destroy()
					local stone = game.ReplicatedStorage.BlockDatabase.Snow:Clone()
					stone.Parent = chunkModel
						stone.Position = Vector3.new((chunkX*chunk_size)*size+chunk_size+(x*size), height+y*size, (chunkZ*chunk_size)*size+chunk_size+(z*size))
				end

			end
		
		end
	end
	
end)

This is the main script for creating chunks.

Only create parts for the “outer layer” of blocks, i.e. blocks that have at least 1 air block neighbor. When a block is mined, update it to see if it has become part of the outer layer.